diff --git a/.gitignore b/.gitignore index 720cb23..426703d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ build/ doc/ .vscode/ .idea/ +pubspec_overrides.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af7d65..5e5cce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,130 @@ +## 6.0.0 +__Breaking changes__ +- Update nyxx to version 6.0.0. + +__Bug fixes__ +- Fixes a type error that could occur when using FallbackConverter's toButton and toMultiselect. + +## 6.0.0-dev.1 +__Breaking changes__ +- Update nyxx to version 6.0.0. See the changelog at https://pub.dev/packages/nyxx for more details. + +## 5.0.2 +__Bug fixes__ +- Fix disposing the plugin partway through command execution causing errors. + +## 5.0.1 +__Bug fixes__ +- Fix component timeouts triggering instantly. +- Fix component wrappers causing null assertions to trigger. + +## 5.0.0 +__Breaking changes__ +- Removed all deprecated APIs. +- APIs which used to take `Type` objects now take `RuntimeType`s for the relevant type. +- APIs which used to take the `customId` of a component now take a `ComponentId`. +- Context types have been reorganized. See the docs for `IContextData`, `ICommandContext` and `IInteractiveContext` for more. +- Converter & check APIs now take `IContextData` objects instead of `IContext` objects. +- Checks now use named parameters instead of positional ones in their constructors. +- `IInteractiveContext.respond` (formerly `IContext.respond`) now takes a `ResponseLevel` object instead of `private` and `hidden`. +- The `interactions` field on `CommandsPlugin` is now nullable to avoid a `late` modifier. Use `IContextData.interactions` instead for a non nullable field. + +__New features__ +- Contexts are now managed by a `ContextManager` which allows users to create their own contexts. +- Added support for modal helpers. See `IInteractionInteractiveContext.getModal` for more. +- Added new errors: `ConverterFailedException`, `InteractionTimeoutException`, `UncaughtCommandsException` and `UnhandledInteractionException`. +- Events & listeners are now handled by an `EventManager` and `ComponentId`s. +- Prefix callbacks can now be asynchronous and return any `Pattern`. +- Added `autoAcknowledgeDuration` for more control over auto-acknowledge. +- Added parsing utilities on `AutocompleteContext` for parsing arguments. +- Contexts in a command are now chained, so interaction expiry and inconsistent formatting of responses to commands are no longer an issue. See `IInteractiveContext.delegate` for more. +- Added many helpers for handling message components: + - `awaitButtonPress`, `awaitSelection` and `awaitMultiSelection` for using fully custom components with nyxx_commands; + - `getButtonPress`, `getButtonSelection` and `getConfirmation` for handling buttons; + - `getSelection` and `getMultiSelection` for handling multiselect menus. +- Added `SimpleConverter` to simplify creating custom converters. +- The prefix callback can now be set to null to disable message commands. This will change the default command type to `slashOnly` unless `CommandsOptions.inferDefaultCommandType` is set to `false`. +- Added `skipPattern` to `StringView`, similar to `skipString`. + +__Bug fixes__ +- Fixed a bug that prevented `part` files from being compiled. +- Fixed a bug that prevented enum parameters from being compiled. +- Fixed nested command `fullName`s not being correct. + +__Miscellaneous__ +- Optimized the compilation script to generate less code and use a more reliable subtype checking method. +- Instructions for compilation can now be found at the package README. +- Bump `nyxx` to 5.0.0 and `nyxx_interactions` to 4.6.0. + +## 5.0.0-dev.3 +__Bug fixes__ +- Fixed a bug which caused `IInteractiveContext.respond` to error after auto-acknowledge. +- Fixed a bug where `getSelection` and `getMultiSelection` would result in an "Interaction failed" error, despite the response being sent. +- Fixed a bug that caused a late initialization error to occur if an error occurred in `respond`. +- Fixed `getSelection` sending a new message for different pages instead of editing the same message. + +## 5.0.0-dev.2 +__Breaking changes__ +- The `DartType` class introduced in 5.0.0-dev.0 has been replaced with `RuntimeType` from [`package:runtime_type`](https://pub.dev/packages/runtime_type). +- All errors thrown by command callbacks are now caught instead of only subclasses of `Exception`. The relevant fields on `UncaughtException` and `AutocompleteFailedException` have therefore been changed from `Exception` to `Object`. +- APIs that took a combination of user, timeout and component id have been changed to use the new `ComponentId` class. + +__New features__ +- Errors will now be added to `CommandsPlugin.onCommandError` when a message component created by nyxx_commands enters an invalid state (e.g no handler found or the user was not allowed to use the component). See the docs for `UnhandledInteractionException` for more. +- Added `ComponentId` as a way for nyxx_commands to generate an ID for message components that contains information about the component's state in nyxx_commands. +- Added a new `InteractionTimeoutException` thrown when an interaction times out instead of Dart's `TimeoutException`. +- Added a `stackTrace` getter to all `CommandsExceptions`. + +__Bug fixes__ +- Fixed an issue where enum values in annotations caused the compiler to crash. + +__Miscellaneous__ +- Added documentation with instructions on how to compile nyxx_commands to the README. +- Correctly export `ContextManager`. +- Changed the log message for uncaught exceptions. The message no longer contains the error description, instead passing the error object through the log record's error field. Versions of nyxx after 4.5.0 contain a `Logging` plugin that will display this error for you. + +## 5.0.0-dev.1 +__Breaking changes__: +- `CommandsPlugin` has been made more type safe, making the `interactions` field nullable. To use the `IInteractions` instance from your commands, see `IContextData.interactions`. `client` has also been changed to be read-only. + +__New features__: +- A helper for using modals has been added. See `IInteractionInteractiveContext.getModal` for more. +- `getSelection` and `getMultiSelection` from `IInteractiveContext` can now be used without a converter, using the `toMultiSelect` parameter. +- Failed converters now throw a `ConverterFailedException` instead of a `BadInputException`. +- `SimpleConverter.provider` can now be async. + +__Bug fixes__: +- `IChatCommandComponent.fullName` now correctly returns the full command name. +- Responding to a component context now correctly clears components on the message. + +__Miscellaneous__: +- `package:analyzer` has been bumped to 5.0.0. +- A few elements that were previously unexported are now correctly exported. + +## 5.0.0-dev.0 +__Breaking changes__: +- `ChatCommand.type` has been moved to `CommandOptions`. Use `ChatCommand(options: CommandOptions(type: ...))` instead of `ChatCommand(type: ...)` to set a commands type. With this change, the `textOnly` and `slashOnly` constructors have been removed from `ChatCommand`. +- `Converter`s no longer take an `IContext` as a parameter but now take an `ICommandContextData`. +- Some of the arguments in `Check` constructors have been changed from positional to named arguments. +- All deprecated fields have been removed. +- `IInteractiveContext.respond` (previously `IContext.respond`) now takes a `ResponseLevel` instead of the context-type-specific named parameters. See `ResponseLevel` for more. +- All uses of `Type`s in the package have been replaced with `DartType`s. This wrapper class allows for sound type safety and simplifies compilation. Notable places this change has an effect are in `CommandsPlugin.getConverter` and `NoConverterException.type`. +- The old component wrappers have been replaced with newer, more versatile methods. + +__New features__: +- The `prefix` function used to determine the prefix for a given text message can now return a `Pattern` and be asynchronous. This allows the use of `RegExp`s to determine command prefixes. +- `CommandsPlugin.contextManager` can be used to create your own contexts from raw events. +- `SimpleConverter` is a new `Converter` that simplifies the creation of custom converters. Providing a function to generate items and a function to stringify each item will create a converter with support for basic conversion, autocompletion and more. +- The prefix is now nullable in the `CommandsPlugin` constructor. Setting it to `null` will make the default command type automatically be `slashOnly` if `CommandsOptions.inferDefaultCommandType` is `true`. +- Commands will now respond to the latest interaction instead of the original interaction if the component wrappers on `IInteractiveContext` are used. See `IInteractiveContext.delegate` for more. +- `CommandOptions.preserveComponentMessages` can be used to choose whether Message Component responses should overwrite the message. +- `CommandOptions.autoAcknowledgeDuration` can be used to manually set the auto-acknowledge timeout. +- `CommandOptions.caseInsensitiveCommands` can be used to allow commands to be invoked case-insensitively. +- `AutocompleteContext` has new methods for parsing values in the autocompletion event. + +__Bug fixes__: +- Returning `null` in an autocomplete handler no longer displays an error in the Discord UI. + ## 4.4.1 __Bug fixes__: - Fix `part` directives breaking compilation. @@ -23,7 +150,7 @@ __New features__: - Added a script which allows `nyxx_commands` to be compiled. For more information, run `dart pub global activate nyxx_commands` and `nyxx-compile --help`. - Implemented support for permissions V2. See `PermissionsCheck` for more. -__Deprecaations__: +__Deprecations__: - Deprecated `AbstractCheck.permissions` and all associated features. ## 4.2.0-dev.1 @@ -35,7 +162,7 @@ __Deprecations__: - Deprecated `AbstractCheck.permissions` and all associated features. __New features__: -- Added `AbtractCheck.allowsDm` and `AbstractCheck.requiredPermissions` for integrating checks with permissions v2. +- Added `AbstractCheck.allowsDm` and `AbstractCheck.requiredPermissions` for integrating checks with permissions v2. - Updated `Check.deny`, `Check.any` and `Check.all` to work with permissions v2. - Added `PermissionsCheck`, for checking if users have a specific permission. @@ -61,7 +188,7 @@ __New features__: - 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 an issue with `IContext.getButtonPress` not behaving correctly 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__: @@ -98,7 +225,7 @@ __Breaking changes__: __Bug fixes__ - Fix `UserCommandCheck` always failing. -- Fix parsing muultiple arguments at once leading to race conditions. +- Fix parsing multiple arguments at once leading to race conditions. - Fix a casting error that occurred when a text command was not found. __Documentation__: @@ -106,7 +233,7 @@ __Documentation__: __New features__: - Added support for the `attachment` command option type. Use `IAttachment` (from `nyxx_interactions`) as the argument type in your commands callback for `nyxx_commands` to register it as an attachment command option. -- Added `IInteractionContext`, an interface implemented by all contexts originating from intetractions. +- Added `IInteractionContext`, an interface implemented by all contexts originating from interactions. ## 4.0.0-dev.1.2 __Bug fixes__: @@ -119,7 +246,7 @@ __Bug fixes__: ## 4.0.0-dev.1 __New features__: - Export the command types for better typing. See the documentation for `ICallHooked`, `IChatCommandComponent`, `IChecked`, `ICommand`, `ICommandGroup`, `ICommandRegisterable` and `IOptions` for more information. -- Add new checks for allowing certain checks to be bypassed by certain command types. See the documentation for `ChatCommandCheck`, `InteractionCommandCheck`, `InterationChatCommandCheck`, `MessageChatCommandCheck`, `MessageCommandCheck` and `UserCommandCheck` for more info. +- Add new checks for allowing certain checks to be bypassed by certain command types. See the documentation for `ChatCommandCheck`, `InteractionCommandCheck`, `InteractionChatCommandCheck`, `MessageChatCommandCheck`, `MessageCommandCheck` and `UserCommandCheck` for more info. - Export `registerDefaultConverters` and `parse` for users wanting to implement their own commands plugin. ## 4.0.0-dev.0 @@ -132,8 +259,8 @@ __Breaking changes__: If you find any more breaking changes please notify us on the official nyxx Discord server, or open an issue on GitHub. __New features__: -- Support for User Application Commands has been addded. They can be created through the `UserCommand` class similarly to `ChatCommand`s, and must be added with `CommandsPlugin.addCommand()` as `ChatCommand`s are. -- Support for Message Application Commands has been addded. They can be created through the `MessageCommand` class similarly to `ChatCommand`s, and must be added with `CommandsPlugin.addCommand()` as `ChatCommand`s are. +- Support for User Application Commands has been added. They can be created through the `UserCommand` class similarly to `ChatCommand`s, and must be added with `CommandsPlugin.addCommand()` as `ChatCommand`s are. +- Support for Message Application Commands has been added. They can be created through the `MessageCommand` class similarly to `ChatCommand`s, and must be added with `CommandsPlugin.addCommand()` as `ChatCommand`s are. - Better support for command configuration has been added. Users can now specify options to apply only to specific commands through the `options` parameter in all command constructors with the new `CommandOptions` class. Similarly to checks, these options are inherited but can be overridden by children. - Added a few simple functions for easier interaction with `nyxx_interactions` covering common use cases for interactions. @@ -168,7 +295,7 @@ __New features__: - Added a new `hideOriginalResponse` option to `CommandsOptions` that allows you to hide the automatic acknowledgement of interactions with `autoAcknowledgeInteractions`. - Added a new `acknowledge` method to `InteractionContext` that allows you to override `hideOriginalResponse`. - Added a new `hideOriginalResponse` parameter to `Command` constructors that allows you to override `CommandsOptions.hideOriginalResponse` on a per-command basis. -- Added a new `hidden` parameter to `InteractionContext.respond` that allows you to send an ephemeral response. The hidden state of the response sent is guaranteed to match the `hidden` parameter, however to avoid strange behaviour it is recommended to acknowledge the interaction with `InteractionContext.acknowledge` if the response is delayed. +- Added a new `hidden` parameter to `InteractionContext.respond` that allows you to send an ephemeral response. The hidden state of the response sent is guaranteed to match the `hidden` parameter, however to avoid strange behavior it is recommended to acknowledge the interaction with `InteractionContext.acknowledge` if the response is delayed. - Added a new `mention` parameter to `MessageContext.respond` that allows you to specify whether the reply to the command should mention the user or not. - Added a new `UseConverter` decorator that allows you to override the converter used to parse a specific argument. - Added converters for `double`s and `Mentionable`s. @@ -180,14 +307,14 @@ __Miscellaneous__: - Argument parsing is now done in parallel, making commands with multiple arguments faster to invoke. __Deprecations__: -- Setting the Discord slash command option type to use for a Dart `Type` via the `discordTypes` map is now deprecated. Use the `type` parameter in converter consutrctors instead. +- Setting the Discord slash command option type to use for a Dart `Type` via the `discordTypes` map is now deprecated. Use the `type` parameter in converter constructors instead. - `Context.send` is now deprecated as `Context.respond` is more appropriate for most cases. If `Context.send` was really what you wanted, use `Context.channel.sendMessage` instead. ## 3.0.0 __Breaking changes__: - The base `Bot` class has been replaced with a `CommandsPlugin` class that can be used as a plugin with nyxx `3.0.0`. - `nyxx` and `nyxx_interactions` dependencies have been bumped to `3.0.0`; versions `2.x` are now unsupported. -- `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. +- `BotOptions` has been renamed to `CommandsOptions` and no longer supports the options found in `ClientOptions`. Create two separate 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 @@ -198,7 +325,7 @@ __New features__: - A new `acceptBotCommands` option has been added to `BotOptions` to allow executing commands from messages sent by other bot users. - A new `acceptSelfCommands` options has been added to `BotOptions` to allow executing commands from messages sent by the bot itself. - `onPreCall` and `onPostCall` streams on `Commands` and `Groups` can be used to register pre- and post- call hooks. -- `AbstractCheck` class can be exetended to implement stateful checks. +- `AbstractCheck` class can be extended to implement stateful checks. - `CooldownCheck` can be used to apply a cooldown to a command based on different criteria. - `InteractionCheck` and `MessageCheck` can be used with `Check.any()` to allow slash commands or text commands to bypass other checks. - `Check.all()` can be used to group checks. @@ -215,7 +342,7 @@ __Breaking changes__: - Exceptions have been reworked and are no longer named the same. __New features__: -- Converters can now specify pre-defined choices for their type, this behaviour can be overridden on a per-command basis with the `@Choices` decorator. +- Converters can now specify pre-defined choices for their type, this behavior can be overridden on a per-command basis with the `@Choices` decorator. - Command arguments can now have custom names with the `@Name` decorator. ## 0.3.0 diff --git a/README.md b/README.md index 9515b76..920a362 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,59 @@ -# Nyxx commands +# nyxx_commands -Nyxx commands is a framework for easily creating slash commands and text commands for Discord using the [nyxx](https://pub.dev/packages/nyxx) library. +nyxx_commands is a framework for easily creating slash commands and text commands for Discord using the [nyxx](https://pub.dev/packages/nyxx) library. -Insipred by [discord.py](https://discordpy.readthedocs.io/en/stable/)'s [commands](https://discordpy.readthedocs.io/en/stable/ext/commands/index.html) extension. +Inspired by [discord.py](https://discordpy.readthedocs.io/en/stable/)'s [commands](https://discordpy.readthedocs.io/en/stable/ext/commands/index.html) extension. + +Need help with nyxx_commands? Join our [Discord server](https://discord.gg/nyxx) and ask in the `#nyxx_commands` channel. ## Features - Easy command creation - Automatic compatibility with Discord slash commands - Compatibility with the [nyxx](https://pub.dev/packages/nyxx) library - Argument parsing + +## Compiling nyxx_commands + +If you compile a bot using nyxx_commands with `dart compile exe`, you might get an error you hadn't seen during development: +``` +Error: Function data was not correctly loaded. Did you compile the wrong file? +Stack trace: +#0 loadFunctionData (package:nyxx_commands/src/mirror_utils/compiled.dart:10) +#1 ChatCommand._loadArguments (package:nyxx_commands/src/commands/chat_command.dart:354) +... +``` + +This is because nyxx_commands uses `dart:mirrors` to load the arguments for your commands. During development this is fine but this functionality breaks when compiled because [`dart:mirrors` cannot be used in compiled Dart programs](https://api.dart.dev/dart-mirrors/dart-mirrors-library.html#status-unstable). + +To mitigate this, nyxx_commands provides a script that compiles your code for you and loads this information for nyxx_commands to use. To use it, you must first wrap all your command callbacks in [`id`](https://pub.dev/documentation/nyxx_commands/latest/nyxx_commands/id.html) and give each function a unique id. + + +For example, this chat command: +```dart +final ping = ChatCommand( + 'ping', + 'Ping the bot', + (IChatContext context) => context.respond(MessageBuilder.content('Pong!')), +); +``` +must have its callback wrapped with `id` like so: +```dart +final ping = ChatCommand( + 'ping', + 'Ping the bot', + id('ping', (IChatContext context) => context.respond(MessageBuilder.content('Pong!'))), +); +``` + +If you forget to add the `id` to a function, you'll get an error similar to this one: +``` +Error: Command Exception: Couldn't load function data for function Closure: (IChatContext) => Null +Stack trace: +#0 loadFunctionData (package:nyxx_commands/src/mirror_utils/compiled.dart:18) +#1 ChatCommand._loadArguments (package:nyxx_commands/src/commands/chat_command.dart:354) +... +``` + +Once you've added `id` to all your commands, use the `nyxx_commands:compile` script to compile your program. See `dart run nyxx_commands:compile --help` for a list of options. + +If you use the `--no-compile` flag, make sure that you run/compile the generated file and not your own main file. diff --git a/analysis_options.yaml b/analysis_options.yaml index d6cb7dc..a9f0d67 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,5 +9,4 @@ analyzer: exclude: [build/**, "*.g.dart"] language: strict-raw-types: true - strong-mode: - implicit-casts: false + strict-casts: false diff --git a/bin/compile/element_tree_visitor.dart b/bin/compile/element_tree_visitor.dart index d4cdd0e..2cb9f3a 100644 --- a/bin/compile/element_tree_visitor.dart +++ b/bin/compile/element_tree_visitor.dart @@ -128,7 +128,12 @@ class EntireAstVisitor extends RecursiveAstVisitor { void visitPartDirective(PartDirective directive) { super.visitPartDirective(directive); + DirectiveUri uri = directive.element!.uri; + if (uri is! DirectiveUriWithSource) { + throw CommandsError('Unknown part target $directive'); + } + // Visit "part-ed" files of interesting sources - _interestingSources.add((directive.element!.uri as DirectiveUriWithSource).source.fullName); + _interestingSources.add(uri.source.fullName); } } diff --git a/bin/compile/function_metadata/metadata_builder.dart b/bin/compile/function_metadata/metadata_builder.dart index 8e00e8b..ce24a45 100644 --- a/bin/compile/function_metadata/metadata_builder.dart +++ b/bin/compile/function_metadata/metadata_builder.dart @@ -16,10 +16,9 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/src/dart/element/element.dart'; -import 'package:nyxx_commands/nyxx_commands.dart'; +import 'package:nyxx_commands/nyxx_commands.dart' show CommandsError; import '../generator.dart'; -import '../type_tree/tree_builder.dart'; import 'compile_time_function_data.dart'; /// Convert [idCreations] into function metadata. @@ -66,36 +65,37 @@ Iterable getFunctionData( } /// Extracts all the annotations on a parameter that have a type with the type id [type]. - Iterable annotationsWithType(int type) { - Iterable constructorAnnotations = parameter.metadata - .where((node) => node.elementAnnotation?.element is ConstructorElement) - .where((node) => - getId((node.elementAnnotation!.element as ConstructorElement) - .enclosingElement - .thisType) == - type); - - Iterable constVariableAnnotations = parameter.metadata - .where((node) => (node.elementAnnotation?.element is ConstVariableElement)) - .where((node) => - getId((node.elementAnnotation!.element as ConstVariableElement) - .evaluationResult! - .value! - .type) == - type); - - return constructorAnnotations.followedBy(constVariableAnnotations); + Iterable annotationsWithType(String source) { + return parameter.metadata.where((element) { + ElementAnnotation? annotation = element.elementAnnotation; + if (annotation == null) { + return false; + } + + DartObject? result = annotation.computeConstantValue(); + if (annotation.constantEvaluationErrors?.isEmpty != true || result == null) { + return false; + } + + return result.type?.element?.location?.encoding == source; + }); } - Iterable nameAnnotations = annotationsWithType(nameId); - - Iterable descriptionAnnotations = annotationsWithType(descriptionId); - - Iterable choicesAnnotations = annotationsWithType(choicesId); - - Iterable useConverterAnnotations = annotationsWithType(useConverterId); - - Iterable autocompleteAnnotations = annotationsWithType(autocompleteId); + Iterable nameAnnotations = annotationsWithType( + 'package:nyxx_commands/src/util/util.dart;package:nyxx_commands/src/util/util.dart;Name', + ); + Iterable descriptionAnnotations = annotationsWithType( + 'package:nyxx_commands/src/util/util.dart;package:nyxx_commands/src/util/util.dart;Description', + ); + Iterable choicesAnnotations = annotationsWithType( + 'package:nyxx_commands/src/util/util.dart;package:nyxx_commands/src/util/util.dart;Choices', + ); + Iterable useConverterAnnotations = annotationsWithType( + 'package:nyxx_commands/src/util/util.dart;package:nyxx_commands/src/util/util.dart;UseConverter', + ); + Iterable autocompleteAnnotations = annotationsWithType( + 'package:nyxx_commands/src/util/util.dart;package:nyxx_commands/src/util/util.dart;Autocomplete', + ); if ([ nameAnnotations, @@ -179,7 +179,7 @@ Iterable getFunctionData( return result; } -/// Extract the object referenced or creatted by an annotation. +/// Extract the object referenced or created by an annotation. DartObject getAnnotationData(ElementAnnotation annotation) { DartObject? result; if (annotation.element is ConstructorElement) { diff --git a/bin/compile/function_metadata/metadata_builder_visitor.dart b/bin/compile/function_metadata/metadata_builder_visitor.dart index bc868b7..f4345c5 100644 --- a/bin/compile/function_metadata/metadata_builder_visitor.dart +++ b/bin/compile/function_metadata/metadata_builder_visitor.dart @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:analyzer/dart/analysis/analysis_context.dart'; import 'package:analyzer/dart/ast/ast.dart'; import '../element_tree_visitor.dart'; @@ -21,7 +20,7 @@ import '../element_tree_visitor.dart'; class FunctionBuilderVisitor extends EntireAstVisitor { final List ids = []; - FunctionBuilderVisitor(AnalysisContext context, bool slow) : super(context, slow); + FunctionBuilderVisitor(super.context, super.slow); @override void visitMethodInvocation(MethodInvocation node) { diff --git a/bin/compile/generator.dart b/bin/compile/generator.dart index 488072e..99cfe2d 100644 --- a/bin/compile/generator.dart +++ b/bin/compile/generator.dart @@ -28,9 +28,6 @@ import 'function_metadata/compile_time_function_data.dart'; import 'function_metadata/metadata_builder_visitor.dart'; import 'function_metadata/metadata_builder.dart'; import 'to_source.dart'; -import 'type_tree/type_builder_visitor.dart'; -import 'type_tree/tree_builder.dart'; -import 'type_tree/type_data.dart'; final Logger logger = Logger('Commands Compiler'); @@ -64,13 +61,10 @@ Future generate(String path, String outPath, bool formatOutput, bool slow) throw CommandsException('No entry point was found for file "$path"'); } - Map typeTree = await processTypes(result.element, context, slow); - Iterable functions = await processFunctions(result.element, context, slow); String output = generateOutput( - {...typeTree.values}, functions, result.element.source.uri.toString(), result.element.entryPoint!.parameters.isNotEmpty, @@ -84,27 +78,6 @@ Future generate(String path, String outPath, bool formatOutput, bool slow) logger.finest('Done'); } -/// Generates type metadata for [result] and all child units (includes imports, exports and parts). -Future> processTypes( - LibraryElement result, - AnalysisContext context, - bool slow, -) async { - logger.info('Building type tree from AST'); - - final TypeBuilderVisitor typeBuilder = TypeBuilderVisitor(context, slow); - - await typeBuilder.visitLibrary(result); - - logger.fine('Found ${typeBuilder.types.length} type instances'); - - final Map typeTree = buildTree(typeBuilder.types); - - logger.info('Finished building type tree with ${typeTree.length} entries'); - - return typeTree; -} - /// Generates function metadata for all creations of [id] invocations in [result] and child units. Future> processFunctions( LibraryElement result, @@ -136,7 +109,6 @@ Future> processFunctions( /// If [formatOutput] is set, the generated content will be passed through `dart format` before /// being returned. String generateOutput( - Iterable typeTree, Iterable functionData, String pathToMainFile, bool hasArgsArgument, @@ -144,8 +116,13 @@ String generateOutput( ) { logger.info('Generating output'); + // Import directives that will be placed at the start of the file + // Other steps in the generation process can add items to this set in order to import types from + // other files + Set imports = {}; + // The base stub: - // - Imports the nyxx_commands runtime type data classes so we can instanciate them + // - Imports the nyxx_commands runtime type data classes so we can instantiate them // - Imports the specified program entrypoint so we can call it later // - Imports `dart:core` so we don't remove it from the global scope by importing it with an alias // - Adds a warning comment to the top of the file @@ -157,54 +134,21 @@ String generateOutput( // Auto-generated file // DO NOT EDIT - // Type data - - '''); - - // Import directives that will be placed at the start of the file - // Other steps in the generation process can add items to this set in order to import types from - // other files - Set imports = {}; - - typeTree = typeTree.toSet(); - - writeTypeMetadata(typeTree, result); - - result.write(''' - - // Nullable typedefs - - '''); - - Set successfulIds = writeTypeDefs(typeTree, result, imports); - - result.write(''' - - // Type mappings - - '''); - - writeTypeMappings(successfulIds, result); - - result.write(''' - // Function data '''); - writeFunctionData(functionData, result, imports, successfulIds); + writeFunctionData(functionData, result, imports); result.write(''' - - // Main function wrapper - void main(List args) { - loadData(typeTree, typeMappings, functionData); - _main.main(${hasArgsArgument ? 'args' : ''}); - } - '''); +// Main function wrapper +void main(List args) { + loadData(functionData); - logger.fine('Formatting output'); + _main.main(${hasArgsArgument ? 'args' : ''}); +} +'''); result = StringBuffer(imports.join('\n'))..write(result.toString()); @@ -212,108 +156,9 @@ String generateOutput( return result.toString(); } - return DartFormatter(lineEnding: '\n').format(result.toString()); -} - -/// Generates the content that represents the type metadata of a program from [typeTree] and writes -/// it to [result]. -void writeTypeMetadata(Iterable typeTree, StringBuffer result) { - result.write('const Map typeTree = {'); - - // Special types - result.write('0: DynamicTypeData(),'); - result.write('1: VoidTypeData(),'); - result.write('2: NeverTypeData(),'); - - // Other types - for (final type in typeTree.whereType()) { - result.write(''' - ${type.id}: InterfaceTypeData( - name: r"${type.name}", - id: ${type.id}, - strippedId: ${type.strippedId}, - superClasses: [${type.superClasses.join(',')}], - typeArguments: [${type.typeArguments.join(',')}], - isNullable: ${type.isNullable}, - ), - '''); - } - - for (final type in typeTree.whereType()) { - result.write(''' - ${type.id}: FunctionTypeData( - name: r"${type.name}", - id: ${type.id}, - returnType: ${type.returnType}, - positionalParameterTypes: [${type.positionalParameterTypes.join(',')}], - requiredPositionalParametersCount: ${type.requiredPositionalParametersCount}, - requiredNamedParametersType: {${type.requiredNamedParametersType.entries.map((entry) => 'r"${entry.key}": ${entry.value}').join(',')}}, - optionalNamedParametersType: {${type.optionalNamedParametersType.entries.map((entry) => 'r"${entry.key}": ${entry.value}').join(',')}}, - isNullable: ${type.isNullable}, - ), - '''); - } - - result.write('};'); -} - -/// Generates a set of `typedef` statements that can be used as keys in maps to represent types, and -/// writes them to [result]. -/// -/// Imports needed to create the typedefs are added to [imports]. -/// -/// This method is needed as nullable type literals are interpreted as ternary statements in some -/// cases, and can lead to errors. Creating a typedef that reflects the same type and using that -/// instead of a type literal avoids this issue. -Set writeTypeDefs(Iterable typeTree, StringBuffer result, Set imports) { - Set successfulIds = {}; - - for (final type in typeTree) { - if (type is DynamicTypeData || type is VoidTypeData || type is NeverTypeData) { - result.write('typedef t_${type.id} = '); - - if (type is DynamicTypeData) { - result.write('dynamic'); - } else if (type is VoidTypeData) { - result.write('void'); - } else { - result.write('Never'); - } - - result.write(';'); - - successfulIds.add(type.id); - continue; - } - - List? typeSourceRepresentation = toTypeSource(type.source); - - if (typeSourceRepresentation == null) { - logger.fine('Excluding type $type as data for type was not resolved'); - continue; - } - - successfulIds.add(type.id); - - imports.addAll(typeSourceRepresentation.skip(2)); - - result.write( - 'typedef t_${type.id}${typeSourceRepresentation.first} = ${typeSourceRepresentation[1]};\n'); - } - - return successfulIds; -} - -/// Generates a map literal that maps runtime [Type] instances to an ID that can be used to look up -/// their metadata, and writes the result to [result]. -void writeTypeMappings(Iterable ids, StringBuffer result) { - result.write('const Map typeMappings = {'); - - for (final id in ids) { - result.write('t_$id: $id,'); - } + logger.fine('Formatting output'); - result.write('};'); + return DartFormatter(lineEnding: '\n').format(result.toString()); } /// Generates a map literal that maps [id] ids to function metadata that can be used to look up @@ -326,7 +171,6 @@ void writeFunctionData( Iterable functionData, StringBuffer result, Set imports, - Set loadedTypeIds, ) { Set loadedIds = {}; @@ -354,11 +198,6 @@ void writeFunctionData( converterSource = converterOverrideData.first; } - if (!loadedTypeIds.contains(getId(parameter.type))) { - logger.shout('Parameter ${parameter.name} has an unresolved type, skipping function'); - continue outerLoop; - } - String? defaultValueSource; if (parameter.defaultValue != null) { @@ -403,7 +242,7 @@ void writeFunctionData( // case where an autocomplete override is specified is when the @Autocomplete annotation // is explicitly used logger.shout( - 'Unable to resolve converter override for parameter ${parameter.name}, skipping function', + 'Unable to resolve autocomplete override for parameter ${parameter.name}, skipping function', ); continue outerLoop; } @@ -448,10 +287,23 @@ void writeFunctionData( localizedDescriptionsSource = localizedDescriptionsData.first; } + List? type = toTypeSource(parameter.type); + if (type == null) { + logger.shout('Parameter ${parameter.name} has an unresolved type, skipping function'); + continue outerLoop; + } + + if (type.first.isNotEmpty) { + logger.shout('Parameter ${parameter.name} uses a type argument which is disallowed.'); + continue outerLoop; + } + + imports.addAll(type.skip(2)); + parameterDataSource += ''' ParameterData( name: "${parameter.name}", - type: t_${getId(parameter.type)}, + type: const RuntimeType<${type[1]}>.allowingDynamic(), isOptional: ${parameter.isOptional}, description: ${parameter.description == null ? 'null' : '"${parameter.description}"'}, defaultValue: $defaultValueSource, diff --git a/bin/compile/to_source.dart b/bin/compile/to_source.dart index d21f447..0fba0da 100644 --- a/bin/compile/to_source.dart +++ b/bin/compile/to_source.dart @@ -357,7 +357,7 @@ List? toExpressionSource(Expression expression) { ]; } else if (referenced.variable is FieldElement) { List? typeData = - toTypeSource((referenced.variable.enclosingElement as ClassElement).thisType); + toTypeSource((referenced.variable.enclosingElement as InterfaceElement).thisType); if (typeData == null || !referenced.variable.isPublic || !referenced.variable.isStatic) { return null; @@ -391,7 +391,8 @@ List? toExpressionSource(Expression expression) { return null; // Cannot handle private functions } } else if (referenced is MethodElement) { - List? typeData = toTypeSource((referenced.enclosingElement as ClassElement).thisType); + List? typeData = + toTypeSource((referenced.enclosingElement as InterfaceElement).thisType); if (typeData == null || !referenced.isPublic || !referenced.isStatic) { return null; @@ -505,7 +506,7 @@ List? toCollectionElementSource(CollectionElement item) { List imports = []; - List? conditionSource = toExpressionSource(item.condition); + List? conditionSource = toExpressionSource(item.expression); if (conditionSource == null) { return null; @@ -546,7 +547,7 @@ List? toCollectionElementSource(CollectionElement item) { ...imports, ]; } else if (item is ForElement) { - // Collection for statement: disallowed beecause it is not const + // Collection for statement: disallowed because it is not const throw CommandsException('Cannot reproduce for loops'); } else if (item is MapLiteralEntry) { // In the case we have a map, we need to convert both the key and the value diff --git a/bin/compile/type_tree/tree_builder.dart b/bin/compile/type_tree/tree_builder.dart deleted file mode 100644 index 0a3b860..0000000 --- a/bin/compile/type_tree/tree_builder.dart +++ /dev/null @@ -1,265 +0,0 @@ -// 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:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/nullability_suffix.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:nyxx_commands/src/errors.dart'; - -import '../generator.dart'; -import 'type_data.dart'; - -final List processing = []; - -/// Returns an ID that uniquely represents [type]. -int getId(DartType type) { - if (processing.contains(type)) { - // Probably a type parameter with a bound that references itself - while (type is TypeParameterType) { - // Since we only care about the bound itself and not the name of the type parameter, we get - // rid of it here - type = type.bound; - } - - logger.finest('Recursive type found; returning hashCode of element ${type.element}'); - - return type.element.hashCode; - } - - processing.add(type); - - int ret; - - if (type is InterfaceType) { - if (type.isDartAsyncFutureOr && - [ - DynamicTypeData().id, - VoidTypeData().id, - NeverTypeData().id, - ].contains(getId(type.typeArguments.first))) { - ret = getId(type.typeArguments.first); - } else { - ret = Object.hashAll([type.hashCode, ...type.typeArguments.map(getId)]); - } - } else if (type is FunctionType) { - ret = Object.hashAll([getId(type.returnType), ...type.parameters.map((e) => getId(e.type))]); - } else if (type is DynamicType) { - ret = DynamicTypeData().id; - } else if (type is VoidType) { - ret = VoidTypeData().id; - } else if (type is NeverType) { - ret = NeverTypeData().id; - } else if (type is TypeParameterType) { - ret = getId(type.bound); - } else { - throw CommandsError('Unhandled type $type'); - } - - processing.removeLast(); - - if (type.nullabilitySuffix == NullabilitySuffix.question) { - // null can be assigned to dynamic, void or Never by default - if (![DynamicTypeData().id, VoidTypeData().id, NeverTypeData().id].contains(ret)) { - ret++; - } - } - - logger.finest('ID for type $type: $ret'); - - return ret; -} - -// We keep track of the IDs of special types - -/// The ID of the [Name] class type. -int get nameId => getId(nameInterfaceElement!.thisType); -InterfaceElement? nameInterfaceElement; - -/// The ID of the [Description] class type. -int get descriptionId => getId(descriptionInterfaceElement!.thisType); -InterfaceElement? descriptionInterfaceElement; - -/// The ID of the [Choices] class type. -int get choicesId => getId(choicesInterfaceElement!.thisType); -InterfaceElement? choicesInterfaceElement; - -/// The ID of the [UseConverter] class type. -int get useConverterId => getId(useConverterInterfaceElement!.thisType); -InterfaceElement? useConverterInterfaceElement; - -/// The ID of the [Autocomplete] class type. -int get autocompleteId => getId(autocompleteInterfaceElement!.thisType); -InterfaceElement? autocompleteInterfaceElement; - -/// The ID of the [Object] class type. -int get objectId => getId(objectInterfaceElement!.thisType); -InterfaceElement? objectInterfaceElement; - -/// The ID of the [Function] class type, -int get functionId => getId(functionInterfaceElement!.thisType); -InterfaceElement? functionInterfaceElement; - -Map, void Function(InterfaceElement)> _specialInterfaceTypeSetters = { - ['package:nyxx_commands/src/util/util.dart', 'Description']: (element) => - descriptionInterfaceElement = element, - ['package:nyxx_commands/src/util/util.dart', 'Name']: (element) => nameInterfaceElement = element, - ['package:nyxx_commands/src/util/util.dart', 'Choices']: (element) => - choicesInterfaceElement = element, - ['package:nyxx_commands/src/util/util.dart', 'UseConverter']: (element) => - useConverterInterfaceElement = element, - ['package:nyxx_commands/src/util/util.dart', 'Autocomplete']: (element) => - autocompleteInterfaceElement = element, - ['dart:core/object.dart', 'Object']: (element) => objectInterfaceElement = element, - ['dart:core/function.dart', 'Function']: (element) => functionInterfaceElement = element, -}; - -/// Update the special types if needed. -void checkSpecialType(DartType type) { - if (type is InterfaceType) { - for (final key in _specialInterfaceTypeSetters.keys) { - if ((type.element.location?.components.contains(key[0]) ?? false) && - type.getDisplayString(withNullability: true) == key[1]) { - logger.finer('Found special type $key: ${type.element}'); - - _specialInterfaceTypeSetters[key]!(type.element); - } - } - } -} - -/// Takes a list of [types] and generates type data from them. -/// -/// The return value is a mapping of type IDs to their type data. -Map buildTree(List types) { - final Map result = {}; - - List processing = []; - - // Type parameters can have a different ID to their actual reflected type, so we merge them at - // the end. - Map toMerge = {}; - - void handle(DartType type) { - int id = getId(type); - - logger.finer('Handling type $type (ID $id)'); - - if (type is TypeParameterType) { - handle(type.bound); - - if (result.containsKey(id) || processing.contains(id)) { - return; - } - - int boundId = getId(type.bound); - - if (processing.contains(boundId)) { - toMerge[boundId] = id; - } else { - result[id] = result[boundId]!; - } - - return; - } - - if (result.containsKey(id) || processing.contains(id)) { - return; - } - - checkSpecialType(type); - - processing.add(id); - - if (type is InterfaceType) { - handle(type.element.thisType); - - for (final superType in type.allSupertypes) { - handle(superType); - } - - for (final typeArgument in type.typeArguments) { - handle(typeArgument); - } - - result[id] = InterfaceTypeData( - name: type.getDisplayString(withNullability: true), - source: type, - id: id, - strippedId: getId(type.element.thisType), - superClasses: type.allSupertypes.map(getId).toList(), - typeArguments: type.typeArguments.map(getId).toList(), - isNullable: type.nullabilitySuffix == NullabilitySuffix.question, - ); - } else if (type is FunctionType) { - handle(type.returnType); - - int requiredParameterCount = 0; - List positionalParameterTypes = []; - Map requiredNamedParameters = {}; - Map optionalNamedParameters = {}; - - for (final parameter in type.parameters) { - handle(parameter.type); - - if (parameter.isPositional) { - if (parameter.isRequiredPositional) { - requiredParameterCount++; - } - - positionalParameterTypes.add(getId(parameter.type)); - } else if (parameter.isRequiredNamed) { - requiredNamedParameters[parameter.name] = getId(parameter.type); - } else if (parameter.isOptionalNamed) { - optionalNamedParameters[parameter.name] = getId(parameter.type); - } else { - throw CommandsError('Unknown parameter type $parameter'); - } - } - - result[id] = FunctionTypeData( - name: type.getDisplayString(withNullability: true), - source: type, - id: id, - returnType: getId(type.returnType), - positionalParameterTypes: positionalParameterTypes, - requiredPositionalParametersCount: requiredParameterCount, - requiredNamedParametersType: requiredNamedParameters, - optionalNamedParametersType: optionalNamedParameters, - isNullable: type.nullabilitySuffix == NullabilitySuffix.question, - ); - } else if (type is DynamicType) { - result[id] = DynamicTypeData(); - } else if (type is VoidType) { - result[id] = VoidTypeData(); - } else if (type is NeverType) { - result[id] = NeverTypeData(); - } else { - throw CommandsError('Couldn\'t generate type data for type "${type.runtimeType}"'); - } - - processing.removeLast(); - - for (final key in toMerge.keys.toList()) { - if (!processing.contains(key)) { - result[toMerge.remove(key)!] = result[key]!; - } - } - } - - for (final type in types) { - handle(type); - } - - return result; -} diff --git a/bin/compile/type_tree/type_builder_visitor.dart b/bin/compile/type_tree/type_builder_visitor.dart deleted file mode 100644 index e71e873..0000000 --- a/bin/compile/type_tree/type_builder_visitor.dart +++ /dev/null @@ -1,76 +0,0 @@ -// 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:analyzer/dart/analysis/analysis_context.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/element/type.dart'; - -import '../element_tree_visitor.dart'; -import '../generator.dart'; - -/// An AST visitor that collects all the types referenced in an entire program. -class TypeBuilderVisitor extends EntireAstVisitor { - final List types = []; - - TypeBuilderVisitor(AnalysisContext context, bool slow) : super(context, slow); - - @override - void visitClassDeclaration(ClassDeclaration node) { - types.add(node.declaredElement!.thisType); - - logger.finest('Found class delcaration ${node.name}'); - - super.visitClassDeclaration(node); - } - - @override - void visitMixinDeclaration(MixinDeclaration node) { - types.add(node.declaredElement!.thisType); - - logger.finest('Found mixin declaration ${node.name}'); - - super.visitMixinDeclaration(node); - } - - @override - void visitFormalParameterList(FormalParameterList node) { - for (final parameter in node.parameterElements) { - types.add(parameter!.type); - - logger.finest('Found parameter type ${parameter.type}'); - } - - super.visitFormalParameterList(node); - } - - @override - void visitTypeLiteral(TypeLiteral node) { - types.add(node.type.type!); - - logger.finest('Found type literal $node'); - - super.visitTypeLiteral(node); - } - - @override - void visitTypeArgumentList(TypeArgumentList node) { - for (final argument in node.arguments) { - types.add(argument.type!); - - logger.finest('Found type argument ${argument.type}'); - } - - super.visitTypeArgumentList(node); - } -} diff --git a/bin/compile/type_tree/type_data.dart b/bin/compile/type_tree/type_data.dart deleted file mode 100644 index 2faad31..0000000 --- a/bin/compile/type_tree/type_data.dart +++ /dev/null @@ -1,203 +0,0 @@ -// 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:analyzer/dart/element/type.dart'; - -/// A representation of a type. -abstract class TypeData { - /// The ID of this type. - int get id; - - /// The [DartType] from which this type data was generated. - DartType get source; - - /// The name of this type. - String get name; -} - -/// A nullable type. -abstract class NullableTypeData { - /// Whether this type is nullable. - bool get isNullable; -} - -/// Type data for a class, enum or mixin ("interfaces"). -class InterfaceTypeData implements TypeData, NullableTypeData { - @override - int id; - - @override - final InterfaceType source; - - @override - String name; - - /// The "stripped id" of this type. - /// - /// This is the ID of this same type, but without any specific type arguments. - int strippedId; - - /// The super classes of this type. - List superClasses; - - /// The type arguments of this type. - List typeArguments; - - @override - bool isNullable; - - InterfaceTypeData({ - required this.name, - required this.source, - required this.id, - required this.strippedId, - required this.superClasses, - required this.typeArguments, - required this.isNullable, - }); - - @override - int get hashCode => id; - - @override - operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); - - @override - String toString() => 'InterfaceTypeData[id=$id, ' - 'source=$source, ' - 'name=$name, ' - 'strippedId=$strippedId, ' - 'superClasses=$superClasses, ' - 'typeArguments=$typeArguments, ' - 'isNullable=$isNullable]'; -} - -/// Type data for a function type. -class FunctionTypeData implements TypeData, NullableTypeData { - @override - int id; - - @override - final FunctionType source; - - @override - String name; - - /// The ID of the return type of this function. - int returnType; - - /// The types of the positional parameters of this function. - List positionalParameterTypes; - - /// The number of required parameters of this function. - int requiredPositionalParametersCount; - - /// The types of the optional named parameters of this function. - Map optionalNamedParametersType; - - /// The types of the required named parameters of this function. - Map requiredNamedParametersType; - - @override - bool isNullable; - - FunctionTypeData({ - required this.name, - required this.source, - required this.id, - required this.returnType, - required this.positionalParameterTypes, - required this.isNullable, - required this.requiredPositionalParametersCount, - required this.optionalNamedParametersType, - required this.requiredNamedParametersType, - }); - - @override - int get hashCode => id; - - @override - operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); - - @override - String toString() => 'FunctionTypeData[id=$id, ' - 'source=$source, ' - 'name=$name, ' - 'returnType=$returnType, ' - 'parameterTypes=$positionalParameterTypes, ' - 'isNullable=$isNullable]'; -} - -/// Type data for the `dynamic` type. -class DynamicTypeData implements TypeData { - @override - int id = 0; - - @override - DynamicType get source => throw UnsupportedError('Cannot get source for dynamic'); - - @override - String name = 'dynamic'; - - @override - int get hashCode => id; - - @override - operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); - - @override - String toString() => 'DynamicTypeData'; -} - -/// Type data for the `void` type. -class VoidTypeData implements TypeData { - @override - int id = 1; - - @override - VoidType get source => throw UnsupportedError('Cannot get source for void'); - - @override - String name = 'void'; - - @override - int get hashCode => id; - - @override - operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); - - @override - String toString() => 'VoidTypeData'; -} - -/// Type data for the "Never" type. -class NeverTypeData implements TypeData { - @override - int id = 2; - - @override - NeverType get source => throw UnsupportedError('Cannot get source for Never'); - - @override - String name = 'Never'; - - @override - int get hashCode => id; - - @override - operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); - - @override - String toString() => 'NeverTypeData'; -} diff --git a/example/example.dart b/example/example.dart index 3ebf5e1..5be18e5 100644 --- a/example/example.dart +++ b/example/example.dart @@ -7,12 +7,12 @@ // - Set the environment variable `GUILD` to the ID of the Discord guild (server) you want the // commands to be registered to -// nyxx is needed to create the client & use nyxx classes like IMessage or IGuild +// nyxx is needed to create the client & use nyxx classes like Message or Guild import 'package:nyxx/nyxx.dart'; // nyxx_commands is needed to use the commands plugin import 'package:nyxx_commands/nyxx_commands.dart'; -// To add these dependancies to your project, run: +// To add these dependencies to your project, run: // - `dart pub add nyxx`; // - `dart pub add nyxx_commands` @@ -20,24 +20,11 @@ import 'package:nyxx_commands/nyxx_commands.dart'; import 'dart:io'; import 'dart:math'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; - -void main() { +void main() async { // ====================================== // - // ===== Initialising nyxx_commands ===== // + // ===== Initializing nyxx_commands ===== // // ====================================== // - // Since v3.0.0, nyxx_commands can be used as a plugin with nyxx v3.0.0 - - // To use a plugin, we must first obtain an instance of INyxx: - // nyxx_commands doesn't yet support using INyxxRest, so we have to use INyxxWebsocket - INyxxWebsocket client = NyxxFactory.createNyxxWebsocket( - Platform.environment['TOKEN']!, - // nyxx_commands runs fine without the guild member intent, but it's useful to have it for - // IMember lookup - GatewayIntents.allUnprivileged | GatewayIntents.guildMembers, - ); - // Next, we need to create our plugin. The plugin class used for nyxx_commands is `CommandsPlugin` // and we need to store it in a variable to be able to access it for registering commands and // converters. @@ -45,15 +32,16 @@ void main() { CommandsPlugin commands = CommandsPlugin( // The `prefix` parameter determines what prefix nyxx_commands will use for text commands. // - // It isn't a simple string but a function that takes a single argument, an `IMessage`, and - // returns a `String` indicating what prefix to use for that message. This allows you to have - // different prefixes for different messages, for example you might want the bot to not require - // a prefix when in direct messages. In that case, you might provide a function like this: + // It isn't a simple string but a function that takes a single argument, a `MessageCreateEvent`, + // and returns a `String` indicating what prefix to use for that message. This allows you to + // have/ different prefixes for different messages, for example you might want the bot to not + // require a prefix when in direct messages. In that case, you might provide a function like + // this: // ```dart - // prefix: (message) { - // if (message.startsWith('!')) { + // prefix: (event) { + // if (event.message.content.startsWith('!')) { // return '!'; - // } else if (message.guild == null) { + // } else if (event.guild == null) { // return ''; // } // } @@ -64,18 +52,15 @@ void main() { // The `guild` parameter determines what guild slash commands will be registered to by default. // - // This is useful for testing since registering slash commands globally can take up to an hour, - // whereas registering commands for a single guild is instantaneous. - // // If you aren't testing or want your commands to be registered globally, either omit this // parameter or set it to `null`. - guild: Snowflake(Platform.environment['GUILD']!), + guild: Snowflake.parse(Platform.environment['GUILD']!), // The `options` parameter allows you to specify additional configuration options for the // plugin. // // Generally you can just omit this parameter and use the defaults, but if you want to allow for - // a specific behaviour you can include this parameter to change the default settings. + // a specific behavior you can include this parameter to change the default settings. // // In this case, we set the option `logErrors` to `true`, meaning errors that occur in commands // will be sent to the logger. If you have also added the `Logging` plugin to your client, these @@ -86,18 +71,13 @@ void main() { ), ); - // Next, we add the commands plugin to our client: - client.registerPlugin(commands); - - // We also register a couple other plugins for convenience. - // These aren't needed for nyxx_commands to work. - client - ..registerPlugin(Logging()) - ..registerPlugin(CliIntegration()) - ..registerPlugin(IgnoreExceptions()); - - // Finally, we tell the client to connect to Discord: - client.connect(); + // Now we have our `CommandsPlugin`, we can connect to Discord, making sure to pass our `commands` + // instance to the client's options. + await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged | GatewayIntents.guildMembers, + options: GatewayClientOptions(plugins: [commands, logging, cliIntegration, ignoreExceptions]), + ); // ====================================== // // ======= Registering a command ======== // @@ -126,18 +106,18 @@ void main() { // as an executable. If you just want to run nyxx_commands with `dart run`, this is optional and // you can just pass a normal function to the constructor. // - // The first parameter to this function must be a `IChatContext`. A `IChatContext` allows you to access + // The first parameter to this function must be a `ChatContext`. A `ChatContext` allows you to access // various information about how the command was run: the user that executed it, the guild it // was ran in and a few other useful pieces of information. // `IChatContext` also has a couple of methods that make it easier to respond to commands. // // Since a ping command doesn't have any other arguments, we don't add any other parameters to // the function. - id('ping', (IChatContext context) { + id('ping', (ChatContext context) { // For a ping command, all we need to do is respond with `pong`. // To do that, we can use the `IChatContext`'s `respond` method which responds to the command with // a message. - context.respond(MessageBuilder.content('pong!')); + context.respond(MessageBuilder(content: 'pong!')); }), ); @@ -187,25 +167,25 @@ void main() { ChatCommand( 'coin', 'Throw a coin', - id('throw-coin', (IChatContext context) { + id('throw-coin', (ChatContext context) { bool heads = Random().nextBool(); context.respond( - MessageBuilder.content('The coin landed on its ${heads ? 'head' : 'tail'}!')); + MessageBuilder(content: 'The coin landed on its ${heads ? 'head' : 'tail'}!')); }), ), ], ); // The other way to add a command to a group is using the `ChatGroup`'s `addCommand` method, - // similarly to how we added the `ping` command to the bot earlie. + // similarly to how we added the `ping` command to the bot earlier. throwGroup.addCommand(ChatCommand( 'die', 'Throw a die', - id('throw-die', (IChatContext context) { + id('throw-die', (ChatContext context) { int number = Random().nextInt(6) + 1; - context.respond(MessageBuilder.content('The die landed on the $number!')); + context.respond(MessageBuilder(content: 'The die landed on the $number!')); }), )); @@ -240,8 +220,8 @@ void main() { // As mentioned earlier, all we need to do to add an argument to our command is add it as a // parameter to our execute function. In this case, we take an argument called `message` and of // type `String`. - id('say', (IChatContext context, String message) { - context.respond(MessageBuilder.content(message)); + id('say', (ChatContext context, String message) { + context.respond(MessageBuilder(content: message)); }), ); @@ -285,7 +265,7 @@ void main() { // class is used. // // nyxx_commands registers a few converters by default for commonly used types such as `int`s, - // `double`s, `IMember`s and others. We'll look into creating custom converters later, and for now + // `double`s, `Member`s and others. We'll look into creating custom converters later, and for now // just use the built-in converters. // Using converters is just as simple as using arguments: simply specify the argument name and @@ -296,17 +276,17 @@ void main() { ChatCommand nick = ChatCommand( 'nick', "Change a user's nickname", - // Setting the type of the `target` parameter to `IMember` will make nyxx_commands convert user - // input to instances of `IMember`. - id('nick', (IChatContext context, IMember target, String newNick) async { + // Setting the type of the `target` parameter to `Member` will make nyxx_commands convert user + // input to instances of `Member`. + id('nick', (ChatContext context, Member target, String newNick) async { try { - await target.edit(builder: MemberBuilder()..nick = newNick); - } on IHttpResponseError { - context.respond(MessageBuilder.content("Couldn't change nickname :/")); + await target.update(MemberUpdateBuilder(nick: newNick)); + } on HttpResponseError { + context.respond(MessageBuilder(content: "Couldn't change nickname :/")); return; } - context.respond(MessageBuilder.content('Successfully changed nickname!')); + context.respond(MessageBuilder(content: 'Successfully changed nickname!')); }), ); @@ -336,7 +316,7 @@ void main() { // You can also run the command from a text message, with `!nick *target* *new-nick*`. Unlike // slash commands, there is no way to filter user input before it gets to our bot, so we might end // up with an invalid input. - // If that is the case, the converter for `IMember` will be unable to convert the user input to a + // If that is the case, the converter for `Member` will be unable to convert the user input to a // valid member, and the command will fail with an exception. // // Note that the bot must have the MANAGE_MEMBERS permission and have a higher role than the @@ -360,7 +340,7 @@ void main() { // To start off, we will create a converter for `Shape`s. We will need to create a brand new // converter from scratch for this, since no existing converter can be mapped to a `Shape`. - // To create the converter, we instanciate the `Converter` class. + // To create the converter, we instantiate the `Converter` class. // Note that the variable is fully // typed, the typed generics on `Converter` are filled in. This allows nyxx_commands to know what // the target type of this converter is. @@ -373,7 +353,7 @@ void main() { // The first parameter to the function is an instance of `StringView`. `StringView` allows you // to manipulate and extract data from a `String`, but also allows the next converter to know // where to start parsing its argument from. - // The second parameter is the current `IChatContext` in which the argument is being parsed. + // The second parameter is the current `ChatContext` in which the argument is being parsed. (view, context) { // In our case, we want to return a `Shape` based on the user's input. The `getQuotedWord()` // will get the next quoted word from the input. @@ -397,9 +377,9 @@ void main() { // nyxx_interaction's `ArgChoiceBuilder`, allowing you to specify the choices that will be shown // to the user when running this command from a slash command. choices: [ - ArgChoiceBuilder('Triangle', 'triangle'), - ArgChoiceBuilder('Square', 'square'), - ArgChoiceBuilder('Pentagon', 'pentagon'), + CommandOptionChoiceBuilder(name: 'Triangle', value: 'triangle'), + CommandOptionChoiceBuilder(name: 'Square', value: 'square'), + CommandOptionChoiceBuilder(name: 'Pentagon', value: 'pentagon'), ], ); @@ -413,10 +393,10 @@ void main() { // to a `Dimension`. // To extend an existing `Converter`, we can use the `CombineConverter` class. This takes an - // exising converter and a function to transform the output of the original converter to the + // existing converter and a function to transform the output of the original converter to the // target type. // Similarly to the shape converter, this variable has to be fully typed. The first type argument - // for `CombineConverter` is the target type of the inital `Converter`, and the second is the + // for `CombineConverter` is the target type of the initial `Converter`, and the second is the // target type of the `CombineConverter`. Converter dimensionConverter = CombineConverter( intConverter, @@ -437,46 +417,46 @@ void main() { commands.addConverter(dimensionConverter); // Let's create a command to test our converter out: - ChatCommand favouriteShape = ChatCommand( - 'favourite-shape', - 'Outputs your favourite shape', - id('favourite-shape', (IChatContext context, Shape shape, Dimension dimension) { - String favourite; + ChatCommand favoriteShape = ChatCommand( + 'favorite-shape', + 'Outputs your favorite shape', + id('favorite-shape', (ChatContext context, Shape shape, Dimension dimension) { + String favorite; switch (shape) { case Shape.triangle: if (dimension == Dimension.twoD) { - favourite = 'triangle'; + favorite = 'triangle'; } else { - favourite = 'pyramid'; + favorite = 'pyramid'; } break; case Shape.square: if (dimension == Dimension.twoD) { - favourite = 'square'; + favorite = 'square'; } else { - favourite = 'cube'; + favorite = 'cube'; } break; case Shape.pentagon: if (dimension == Dimension.twoD) { - favourite = 'pentagon'; + favorite = 'pentagon'; } else { - favourite = 'pentagonal prism'; + favorite = 'pentagonal prism'; } } - context.respond(MessageBuilder.content('Your favourite shape is $favourite!')); + context.respond(MessageBuilder(content: 'Your favorite shape is $favorite!')); }), ); - commands.addCommand(favouriteShape); + commands.addCommand(favoriteShape); - // At this point, if you run the file you will see that the `favourite-shape` command has been + // At this point, if you run the file you will see that the `favorite-shape` command has been // added to the slash command menu. // Selecting this command, you will be prompted to select a shape from the choices we outlined // earlier and a dimension. Note that in this case Discord isn't able to give us choices since we - // haven't told it what dimensions are availible. + // haven't told it what dimensions are available. // // If you run the command, you will see that your input will automatically be converted to a // `Shape` and `Dimension` by using the converters we defined earlier. @@ -502,23 +482,22 @@ void main() { // ```dart // (IChatContext context, [String? a, String? b, String? c]) {} // ``` - // In this case, `b` having a value does not guarantee `a` has a value. As such, it is always - // better to provide a default for your optional parameters instead of making them nullable. + // In this case, `b` having a value does not guarantee `a` has a value. // As an example for using optional arguments, let's create a command with an optional argument: - ChatCommand favouriteFruit = ChatCommand( - 'favourite-fruit', - 'Outputs your favourite fruit', - id('favourite-fruit', (IChatContext context, [String favourite = 'apple']) { - context.respond(MessageBuilder.content('Your favourite fruit is $favourite!')); + ChatCommand favoriteFruit = ChatCommand( + 'favorite-fruit', + 'Outputs your favorite fruit', + id('favorite-fruit', (ChatContext context, [String favorite = 'apple']) { + context.respond(MessageBuilder(content: 'Your favorite fruit is $favorite!')); }), ); - commands.addCommand(favouriteFruit); + commands.addCommand(favoriteFruit); - // At this point, if you run the file you will be able to use the `favourite-fruit` command. Once + // At this point, if you run the file you will be able to use the `favorite-fruit` command. Once // you've selected the command in the slash command menu, you'll be given an option to provide a - // value for the `favourite` argument. + // value for the `favorite` argument. // If you don't specify a value for the argument, the default value of `'apple'` will be used. If // you do specify a value, that value will be used instead. // @@ -540,8 +519,8 @@ void main() { ChatCommand alphabet = ChatCommand( 'alphabet', 'Outputs the alphabet', - id('alphabet', (IChatContext context) { - context.respond(MessageBuilder.content('ABCDEFGHIJKLMNOPQRSTUVWXYZ')); + id('alphabet', (ChatContext context) { + context.respond(MessageBuilder(content: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')); }), // Since this command is spammy, we can use a cooldown to restrict its usage: checks: [ @@ -583,10 +562,10 @@ void main() { 'better-say', 'A better version of the say command', id('better-say', ( - IChatContext context, + ChatContext context, @UseConverter(nonEmptyStringConverter) String input, ) { - context.respond(MessageBuilder.content(input)); + context.respond(MessageBuilder(content: input)); }), ); @@ -616,7 +595,7 @@ enum Dimension { // ---------- Global functions ---------- // // -------------------------------------- // -String? filterInput(String input, IContext context) { +String? filterInput(String input, ContextData context) { if (input.isNotEmpty) { return input; } diff --git a/example/example_clean.dart b/example/example_clean.dart index cd8438e..6ae322b 100644 --- a/example/example_clean.dart +++ b/example/example_clean.dart @@ -10,36 +10,26 @@ import 'package:nyxx_commands/nyxx_commands.dart'; import 'dart:io'; import 'dart:math'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; - -void main() { - INyxxWebsocket client = NyxxFactory.createNyxxWebsocket( - Platform.environment['TOKEN']!, - GatewayIntents.allUnprivileged | GatewayIntents.guildMembers, - ); - +void main() async { CommandsPlugin commands = CommandsPlugin( prefix: (message) => '!', - guild: Snowflake(Platform.environment['GUILD']!), + guild: Snowflake.parse(Platform.environment['GUILD']!), options: CommandsOptions( logErrors: true, ), ); - client.registerPlugin(commands); - - client - ..registerPlugin(Logging()) - ..registerPlugin(CliIntegration()) - ..registerPlugin(IgnoreExceptions()); - - client.connect(); + await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged | GatewayIntents.guildMembers, + options: GatewayClientOptions(plugins: [commands, logging, cliIntegration, ignoreExceptions]), + ); ChatCommand ping = ChatCommand( 'ping', 'Checks if the bot is online', - id('ping', (IChatContext context) { - context.respond(MessageBuilder.content('pong!')); + id('ping', (ChatContext context) { + context.respond(MessageBuilder(content: 'pong!')); }), ); @@ -52,11 +42,11 @@ void main() { ChatCommand( 'coin', 'Throw a coin', - id('throw-coin', (IChatContext context) { + id('throw-coin', (ChatContext context) { bool heads = Random().nextBool(); context.respond( - MessageBuilder.content('The coin landed on its ${heads ? 'head' : 'tail'}!')); + MessageBuilder(content: 'The coin landed on its ${heads ? 'head' : 'tail'}!')); }), ), ], @@ -65,10 +55,10 @@ void main() { throwGroup.addCommand(ChatCommand( 'die', 'Throw a die', - id('throw-die', (IChatContext context) { + id('throw-die', (ChatContext context) { int number = Random().nextInt(6) + 1; - context.respond(MessageBuilder.content('The die landed on the $number!')); + context.respond(MessageBuilder(content: 'The die landed on the $number!')); }), )); @@ -77,8 +67,8 @@ void main() { ChatCommand say = ChatCommand( 'say', 'Make the bot say something', - id('say', (IChatContext context, String message) { - context.respond(MessageBuilder.content(message)); + id('say', (ChatContext context, String message) { + context.respond(MessageBuilder(content: message)); }), ); @@ -87,15 +77,15 @@ void main() { ChatCommand nick = ChatCommand( 'nick', "Change a user's nickname", - id('nick', (IChatContext context, IMember target, String newNick) async { + id('nick', (ChatContext context, Member target, String newNick) async { try { - await target.edit(builder: MemberBuilder()..nick = newNick); - } on IHttpResponseError { - context.respond(MessageBuilder.content("Couldn't change nickname :/")); + await target.update(MemberUpdateBuilder(nick: newNick)); + } on HttpResponseError { + context.respond(MessageBuilder(content: "Couldn't change nickname :/")); return; } - context.respond(MessageBuilder.content('Successfully changed nickname!')); + context.respond(MessageBuilder(content: 'Successfully changed nickname!')); }), ); @@ -115,9 +105,9 @@ void main() { } }, choices: [ - ArgChoiceBuilder('Triangle', 'triangle'), - ArgChoiceBuilder('Square', 'square'), - ArgChoiceBuilder('Pentagon', 'pentagon'), + CommandOptionChoiceBuilder(name: 'Triangle', value: 'triangle'), + CommandOptionChoiceBuilder(name: 'Square', value: 'square'), + CommandOptionChoiceBuilder(name: 'Pentagon', value: 'pentagon'), ], ); @@ -139,56 +129,56 @@ void main() { commands.addConverter(dimensionConverter); - ChatCommand favouriteShape = ChatCommand( - 'favourite-shape', - 'Outputs your favourite shape', - id('favourite-shape', (IChatContext context, Shape shape, Dimension dimension) { - String favourite; + ChatCommand favoriteShape = ChatCommand( + 'favorite-shape', + 'Outputs your favorite shape', + id('favorite-shape', (ChatContext context, Shape shape, Dimension dimension) { + String favorite; switch (shape) { case Shape.triangle: if (dimension == Dimension.twoD) { - favourite = 'triangle'; + favorite = 'triangle'; } else { - favourite = 'pyramid'; + favorite = 'pyramid'; } break; case Shape.square: if (dimension == Dimension.twoD) { - favourite = 'square'; + favorite = 'square'; } else { - favourite = 'cube'; + favorite = 'cube'; } break; case Shape.pentagon: if (dimension == Dimension.twoD) { - favourite = 'pentagon'; + favorite = 'pentagon'; } else { - favourite = 'pentagonal prism'; + favorite = 'pentagonal prism'; } } - context.respond(MessageBuilder.content('Your favourite shape is $favourite!')); + context.respond(MessageBuilder(content: 'Your favorite shape is $favorite!')); }), ); - commands.addCommand(favouriteShape); + commands.addCommand(favoriteShape); - ChatCommand favouriteFruit = ChatCommand( - 'favourite-fruit', - 'Outputs your favourite fruit', - id('favourite-fruit', (IChatContext context, [String favourite = 'apple']) { - context.respond(MessageBuilder.content('Your favourite fruit is $favourite!')); + ChatCommand favoriteFruit = ChatCommand( + 'favorite-fruit', + 'Outputs your favorite fruit', + id('favorite-fruit', (ChatContext context, [String favorite = 'apple']) { + context.respond(MessageBuilder(content: 'Your favorite fruit is $favorite!')); }), ); - commands.addCommand(favouriteFruit); + commands.addCommand(favoriteFruit); ChatCommand alphabet = ChatCommand( 'alphabet', 'Outputs the alphabet', - id('alphabet', (IChatContext context) { - context.respond(MessageBuilder.content('ABCDEFGHIJKLMNOPQRSTUVWXYZ')); + id('alphabet', (ChatContext context) { + context.respond(MessageBuilder(content: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')); }), checks: [ CooldownCheck( @@ -206,10 +196,10 @@ void main() { 'better-say', 'A better version of the say command', id('better-say', ( - IChatContext context, + ChatContext context, @UseConverter(nonEmptyStringConverter) String input, ) { - context.respond(MessageBuilder.content(input)); + context.respond(MessageBuilder(content: input)); }), ); @@ -227,7 +217,7 @@ enum Dimension { threeD, } -String? filterInput(String input, IContext context) { +String? filterInput(String input, ContextData context) { if (input.isNotEmpty) { return input; } diff --git a/lib/nyxx_commands.dart b/lib/nyxx_commands.dart index 25752f6..3bc1c58 100644 --- a/lib/nyxx_commands.dart +++ b/lib/nyxx_commands.dart @@ -1,17 +1,3 @@ -// 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. - /// A framework for easily creating slash commands and text commands for Discord using the nyxx library. library nyxx_commands; @@ -32,49 +18,39 @@ export 'src/commands.dart' show CommandsPlugin; export 'src/commands/chat_command.dart' show ChatCommand, ChatGroup, CommandType; export 'src/commands/interfaces.dart' show - ICallHooked, - IChatCommandComponent, - IChecked, - ICommand, - ICommandGroup, - ICommandRegisterable, - IOptions; + CallHooked, + ChatCommandComponent, + Checked, + Command, + CommandGroup, + CommandRegisterable, + Options; 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, IContextBase; -export 'src/context/interaction_context.dart' show IInteractionContext, IInteractionContextBase; + show ChatContext, ChatContextData, InteractionChatContext, MessageChatContext; +export 'src/context/context_manager.dart' show ContextManager; +export 'src/context/base.dart' + show + CommandContext, + CommandContextData, + ContextData, + InteractionCommandContext, + InteractionContextData, + InteractionCommandContextData, + InteractionInteractiveContext, + InteractiveContext, + ResponseLevel; export 'src/context/message_context.dart' show MessageContext; +export 'src/context/modal_context.dart' show ModalContext; export 'src/context/user_context.dart' show UserContext; -export 'src/converters/converter.dart' - show - CombineConverter, - Converter, - DoubleConverter, - FallbackConverter, - GuildChannelConverter, - IntConverter, - NumConverter, - attachmentConverter, - boolConverter, - categoryGuildChannelConverter, - doubleConverter, - guildChannelConverter, - intConverter, - memberConverter, - mentionableConverter, - roleConverter, - snowflakeConverter, - stageVoiceChannelConverter, - stringConverter, - textGuildChannelConverter, - userConverter, - voiceGuildChannelConverter, - registerDefaultConverters, - parse; +export 'src/converters/built_in.dart'; // Barrel file, exports are already filtered +export 'src/converters/combine.dart' show CombineConverter; +export 'src/converters/converter.dart' show Converter, registerDefaultConverters, parse; +export 'src/converters/fallback.dart' show FallbackConverter; +export 'src/converters/simple.dart' show SimpleConverter; export 'src/errors.dart' show AutocompleteFailedException, @@ -85,15 +61,24 @@ export 'src/errors.dart' CommandRegistrationError, CommandsError, CommandsException, + ContextualException, + ConverterFailedException, + InteractionTimeoutException, NoConverterException, NotEnoughArgumentsException, ParsingException, - UncaughtException; + UncaughtCommandsException, + UncaughtException, + UnhandledInteractionException; +export 'src/event_manager.dart' show EventManager; +export 'src/mirror_utils/mirror_utils.dart' show RuntimeType; export 'src/options.dart' show CommandsOptions; export 'src/util/util.dart' show Autocomplete, Choices, + ComponentId, + ComponentIdStatus, Description, Name, UseConverter, diff --git a/lib/src/checks/checks.dart b/lib/src/checks/checks.dart index f490154..0fdac46 100644 --- a/lib/src/checks/checks.dart +++ b/lib/src/checks/checks.dart @@ -1,24 +1,9 @@ -// 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 'dart:async'; import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx_commands/nyxx_commands.dart'; import '../commands.dart'; -import '../context/context.dart'; /// Represents a check on a command. /// @@ -28,7 +13,7 @@ import '../context/context.dart'; /// /// You might also be interested in: /// - [Check], which allows you to construct checks with a simple callback; -/// - [IChecked.check], which allows you to add checks to a command or command group; +/// - [Checked.check], which allows you to add checks to a command or command group; /// - [CheckFailedException], the exception that is thrown and added to /// [CommandsPlugin.onCommandError] when a check fails. abstract class AbstractCheck { @@ -52,26 +37,7 @@ abstract class AbstractCheck { /// /// This check's state should not be changed in [check]; instead, developers should use /// [preCallHooks] and [postCallHooks] to update the check's state. - FutureOr check(IContext context); - - /// The set of [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) - /// this check represents. - /// - /// Any [ICommand] (excluding text-only [ChatCommand]s) will have the permissions from all the - /// checks on that command applied through the Discord Slash Command API. This can allow users to - /// see whether a command is executable from within their Discord client, instead of nyxx_commands - /// rejecting the command once received. - /// - /// A [CommandPermissionBuilderAbstract] with a target ID of `0` will be considered to be the - /// default permission for this check. - /// - /// You might also be interested in: - /// - [CommandPermissionBuilderAbstract.role], for creating slash command permissions that apply - /// to a given role; - /// - [CommandPermissionBuilderAbstract.user], for creating slash command permissions that apply - /// to a given user. - @Deprecated('Use allowsDm and requiredPermissions instead') - final Future> permissions = Future.value([]); + FutureOr check(CommandContext context); /// Whether this check will allow commands to be executed in DM channels. /// @@ -93,8 +59,8 @@ abstract class AbstractCheck { /// /// You might also be interested in: /// - [allowsDm], for controlling whether a command can be executed in a DM; - /// - [PermissionsConstants], for finding the integer that represents a certain permission. - FutureOr get requiredPermissions; + /// - [Permissions], for finding the integer that represents a certain permission. + FutureOr?> get requiredPermissions; /// An iterable of callbacks executed before a command is executed but after all the checks for /// that command have succeeded. @@ -102,20 +68,20 @@ abstract class AbstractCheck { /// These callbacks should be used to update this check's state. /// /// You might also be interested in: - /// - [ICallHooked.onPreCall], for registering arbitrary callbacks to be executed before a command + /// - [CallHooked.onPreCall], for registering arbitrary callbacks to be executed before a command /// is executed but after all checks have succeeded; /// - [CommandsPlugin.onCommandError], where a [CheckFailedException] is added when a check for a /// command fails. - Iterable get preCallHooks; + Iterable get preCallHooks; /// An iterable of callbacks executed after a command is executed. /// /// These callbacks should be used to update this check's state. /// /// You might also be interested in: - /// - [ICallHooked.onPostCall], for registering arbitrary callbacks to be executed after a command + /// - [CallHooked.onPostCall], for registering arbitrary callbacks to be executed after a command /// is executed but after all checks have succeeded. - Iterable get postCallHooks; + Iterable get postCallHooks; @override String toString() => 'Check[name=$name]'; @@ -125,7 +91,8 @@ abstract class AbstractCheck { /// /// See [AbstractCheck] for a description of what a *check* is. /// -/// A [Check] is a simple check with no state, which validates [IContext]s with a single callback. +/// A [Check] is a simple check with no state, which validates [CommandContext]s with a single +/// callback. /// The check succeeds if the callback returns `true` and fails if the callback returns `false`. /// /// For example, to only allow users with "evrything" in their name to execute a command: @@ -159,13 +126,13 @@ abstract class AbstractCheck { /// - [Check.any], [Check.deny] and [Check.all], for modifying the behaviour of checks; /// - [AbstractCheck], which allows developers to create checks with state. class Check extends AbstractCheck { - final FutureOr Function(IContext) _check; + final FutureOr Function(CommandContext) _check; @override final FutureOr allowsDm; @override - final FutureOr requiredPermissions; + final FutureOr?> requiredPermissions; /// Create a new [Check]. /// @@ -173,13 +140,12 @@ class Check extends AbstractCheck { /// failure respectively. [_check] should not throw to indicate failure. /// /// [name] can optionally be provided and will be used in error messages to identify this check. - // TODO: Use named parameters instead of positional parameters Check( - this._check, [ + this._check, { String name = 'Check', this.allowsDm = true, this.requiredPermissions, - ]) : super(name); + }) : super(name); /// Creates a check that succeeds if any of [checks] succeeds. /// @@ -228,29 +194,29 @@ class Check extends AbstractCheck { /// Note that [AbstractCheck.preCallHooks] and [AbstractCheck.postCallHooks] will therefore be executed if [check] /// *fails*, and not when [check] succeeds. Therefore, developers should take care that [check] /// does not assume it succeeded in its call hooks. - static AbstractCheck deny(AbstractCheck check, [String? name]) => _DenyCheck(check, name); + static AbstractCheck deny(AbstractCheck check, {String? name}) => _DenyCheck(check, name: name); /// Creates a check that succeeds if all of [checks] succeed. /// /// This can be used to group checks that are commonly used together into a single, reusable /// check. - static AbstractCheck all(Iterable checks, [String? name]) => - _GroupCheck(checks, name); + static AbstractCheck all(Iterable checks, {String? name}) => + _GroupCheck(checks, name: name); @override - FutureOr check(IContext context) => _check(context); + FutureOr check(CommandContext context) => _check(context); @override - Iterable get postCallHooks => []; + Iterable get postCallHooks => []; @override - Iterable get preCallHooks => []; + Iterable get preCallHooks => []; } class _AnyCheck extends AbstractCheck { Iterable checks; - final Expando _succesfulChecks = Expando(); + final Expando _successfulChecks = Expando(); _AnyCheck(this.checks, [String? name]) : super(name ?? 'Any of [${checks.map((e) => e.name).join(', ')}]') { @@ -260,15 +226,15 @@ class _AnyCheck extends AbstractCheck { } @override - FutureOr check(IContext context) async { + FutureOr check(CommandContext context) async { for (final check in checks) { FutureOr result = check.check(context); if (result is bool && result) { - _succesfulChecks[context] = check; + _successfulChecks[context] = check; return true; } else if (await result) { - _succesfulChecks[context] = check; + _successfulChecks[context] = check; return true; } } @@ -276,9 +242,9 @@ class _AnyCheck extends AbstractCheck { } @override - Iterable get preCallHooks => [ + Iterable get preCallHooks => [ (context) { - AbstractCheck? actualCheck = _succesfulChecks[context]; + AbstractCheck? actualCheck = _successfulChecks[context]; if (actualCheck == null) { logger.warning("Context $context shouldn't have passed checks; actualCheck is null"); @@ -292,9 +258,9 @@ class _AnyCheck extends AbstractCheck { ]; @override - Iterable get postCallHooks => [ + Iterable get postCallHooks => [ (context) { - AbstractCheck? actualCheck = _succesfulChecks[context]; + AbstractCheck? actualCheck = _successfulChecks[context]; if (actualCheck == null) { logger.warning("Context $context shouldn't have passed checks; actualCheck is null"); @@ -319,11 +285,11 @@ class _AnyCheck extends AbstractCheck { } @override - Future get requiredPermissions async { - int result = 0; + Future?> get requiredPermissions async { + Flags result = Permissions(0); for (final check in checks) { - int? permissions = await check.requiredPermissions; + final permissions = await check.requiredPermissions; if (permissions == null) { return null; @@ -339,52 +305,59 @@ class _AnyCheck extends AbstractCheck { class _DenyCheck extends Check { final AbstractCheck source; - _DenyCheck(this.source, [String? name]) - : super((context) async => !(await source.check(context)), name ?? 'Denied ${source.name}'); + _DenyCheck(this.source, {String? name}) + : super( + name: name ?? 'Denied ${source.name}', + (context) async => !(await source.check(context)), + ); // It may seem counterintuitive to call the success hooks if the source check failed, and this is // a situation where there is no proper solution. Here, we assume that the source check will // reset its state on failure after failure, so calling the hooks is desireable. @override - Iterable get preCallHooks => source.preCallHooks; + Iterable get preCallHooks => source.preCallHooks; @override - Iterable get postCallHooks => source.postCallHooks; + Iterable get postCallHooks => source.postCallHooks; @override FutureOr get allowsDm async => !await source.allowsDm; @override - FutureOr get requiredPermissions async { - int? permissions = await source.requiredPermissions; + FutureOr?> get requiredPermissions async { + final permissions = await source.requiredPermissions; if (permissions == null) { return null; } - return ~permissions & PermissionsConstants.allPermissions; + return ~permissions & Permissions.allPermissions; } } class _GroupCheck extends Check { final Iterable checks; - _GroupCheck(this.checks, [String? name]) - : super((context) async { - Iterable> results = checks.map((e) => e.check(context)); + _GroupCheck(this.checks, {String? name}) + : super( + name: name ?? 'All of [${checks.map((e) => e.name).join(', ')}]', + (context) async { + Iterable> results = checks.map((e) => e.check(context)); - Iterable> asyncResults = results.whereType>(); - Iterable syncResults = results.whereType(); + Iterable> asyncResults = results.whereType>(); + Iterable syncResults = results.whereType(); - return !syncResults.contains(false) && !(await Future.wait(asyncResults)).contains(false); - }, name ?? 'All of [${checks.map((e) => e.name).join(', ')}]'); + return !syncResults.contains(false) && + !(await Future.wait(asyncResults)).contains(false); + }, + ); @override - Iterable get preCallHooks => + Iterable get preCallHooks => checks.map((e) => e.preCallHooks).expand((_) => _); @override - Iterable get postCallHooks => + Iterable get postCallHooks => checks.map((e) => e.postCallHooks).expand((_) => _); @override @@ -399,14 +372,21 @@ class _GroupCheck extends Check { } @override - FutureOr get requiredPermissions async { - Iterable permissions = checks.whereType(); + FutureOr?> get requiredPermissions async { + Iterable> permissions = (await Future.wait( + checks.map( + (e) => Future.value( + e.requiredPermissions, + ), + ), + )) + .whereType>(); if (permissions.isEmpty) { return null; } - int result = PermissionsConstants.allPermissions; + Flags result = Permissions.allPermissions; for (final permission in permissions) { result &= permission; diff --git a/lib/src/checks/context_type.dart b/lib/src/checks/context_type.dart index 94dcd40..5f5b02f 100644 --- a/lib/src/checks/context_type.dart +++ b/lib/src/checks/context_type.dart @@ -1,4 +1,6 @@ -import '../context/interaction_context.dart'; +import 'package:nyxx/nyxx.dart'; + +import '../context/base.dart'; import '../context/chat_context.dart'; import '../context/message_context.dart'; import '../context/user_context.dart'; @@ -18,11 +20,8 @@ import 'checks.dart'; /// - [ChatCommandCheck], for checking that the command being invoked is a [ChatCommand]. class InteractionCommandCheck extends Check { /// Create a new [InteractionChatCommandCheck]. - InteractionCommandCheck([String? name]) - : super( - (context) => context is IInteractionContext, - name ?? 'Interaction check', - ); + InteractionCommandCheck({super.name = 'Interaction check'}) + : super((context) => context is InteractionContextData); } /// A check that succeeds if the command being invoked is a [MessageCommand]. @@ -34,11 +33,8 @@ class InteractionCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class MessageCommandCheck extends Check { /// Create a new [MessageCommandCheck]. - MessageCommandCheck([String? name]) - : super( - (context) => context is MessageContext, - name ?? 'Message command check', - ); + MessageCommandCheck({super.name = 'Message command check'}) + : super((context) => context is MessageContext); } /// A check that succeeds if the command being invoked is a [UserCommand]. @@ -50,11 +46,8 @@ class MessageCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class UserCommandCheck extends Check { /// Create a new [UserCommandCheck]. - UserCommandCheck([String? name]) - : super( - (context) => context is UserContext, - name ?? 'User command check', - ); + UserCommandCheck({super.name = 'User command check'}) + : super((context) => context is UserContext); } /// A check that succeeds if the command being invoked is a [ChatCommand]. @@ -72,11 +65,8 @@ class UserCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class ChatCommandCheck extends Check { /// Create a new [ChatCommandCheck]. - ChatCommandCheck([String? name]) - : super( - (context) => context is IChatContext, - name ?? 'Chat command check', - ); + ChatCommandCheck({super.name = 'Chat command check'}) + : super((context) => context is ChatContext); } /// A check that succeeds if the command being invoked is a [ChatCommand] and that the context was @@ -89,15 +79,12 @@ class ChatCommandCheck extends Check { /// See [Check.any] for an example of how to implement this. /// /// You might also be interested in: -/// - [ChatCommandCheck], for checking that the command being exected is a [ChatCommand]; +/// - [ChatCommandCheck], for checking that the command being executed is a [ChatCommand]; /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class InteractionChatCommandCheck extends Check { /// Create a new [InteractionChatCommandCheck]. - InteractionChatCommandCheck([String? name]) - : super( - (context) => context is InteractionChatContext, - name ?? 'Interaction chat command check', - ); + InteractionChatCommandCheck({super.name = 'Interaction chat command check'}) + : super((context) => context is InteractionChatContext); } /// A check that succeeds if the command being invoked is a [ChatCommand] and that the context was @@ -110,15 +97,14 @@ class InteractionChatCommandCheck extends Check { /// See [Check.any] for an example of how to implement this. /// /// You might also be interested in: -/// - [ChatCommandCheck], for checking that the command being exected is a [ChatCommand]. +/// - [ChatCommandCheck], for checking that the command being executed is a [ChatCommand]. class MessageChatCommandCheck extends Check { /// Create a new [MessageChatCommandCheck]. - MessageChatCommandCheck([String? name]) + MessageChatCommandCheck({super.name = 'Message chat command check'}) : super( (context) => context is MessageChatContext, - name ?? 'Message chat command check', - // Disallow command in both guilds and DMs (0 = disable for all members). - false, - 0, + // Don't enable slash commands with this check either in DMs or in guilds. + allowsDm: false, + requiredPermissions: Permissions(0), ); } diff --git a/lib/src/checks/cooldown.dart b/lib/src/checks/cooldown.dart index 63d4f42..eacf1f0 100644 --- a/lib/src/checks/cooldown.dart +++ b/lib/src/checks/cooldown.dart @@ -2,73 +2,64 @@ import 'dart:async'; import 'package:nyxx/nyxx.dart'; -import '../context/context.dart'; +import '../context/base.dart'; import 'checks.dart'; /// An enum that represents the different ways to sort contexts into buckets. /// -/// Coolown types can be combined with the binary OR operator (`|`). For details on how this affects +/// Cooldown types can be combined with the binary OR operator (`|`). For details on how this affects /// how contexts are sorted into buckets, see [CooldownCheck.getKey]. /// /// You might also be interested in: /// - [CooldownCheck], the check that uses this enum. -class CooldownType extends IEnum { +class CooldownType extends Flags { /// A cooldown type that sorts contexts depending on the category they were invoked from. /// /// If the channel the context was created in is not part of a category, then this type behaves /// the same as [channel]. - static const CooldownType category = CooldownType(1 << 0); + static const Flag category = Flag.fromOffset(0); /// A cooldown type that sorts contexts depending on the channel they were invoked from. - static const CooldownType channel = CooldownType(1 << 1); + static const Flag channel = Flag.fromOffset(1); /// A cooldown type that sorts contexts depending on the command being invoked. - static const CooldownType command = CooldownType(1 << 2); + static const Flag command = Flag.fromOffset(2); /// A cooldown type that sorts all contexts into the same bucket. - static const CooldownType global = CooldownType(1 << 3); + static const Flag global = Flag.fromOffset(3); /// A cooldown type that sorts contexts depending on the guild they were invoked from. /// /// If the context was not invoked from a guild, then this type behaves the same as [channel]. - static const CooldownType guild = CooldownType(1 << 4); + static const Flag guild = Flag.fromOffset(4); /// A cooldown type that sorts contexts depending on the highest-level role the user invoking the /// command has. /// - /// If the user has no role, then the [IGuild.everyoneRole] for that guild is used. + /// If the user has no role, then the id of the [Guild] is used. /// If the context was not invoked from a guild, then this type behaves the same as [user]. - static const CooldownType role = CooldownType(1 << 5); + static const Flag role = Flag.fromOffset(5); /// A cooldown type that sorts contexts depending on the user that invoked them. - static const CooldownType user = CooldownType(1 << 6); + static const Flag user = Flag.fromOffset(6); /// Create a new [CooldownType]. /// - /// Using a [value] other than the predefined ones will not result in any new behaviour, so using + /// Using a [value] other than the predefined ones will not result in any new behavior, so using /// this constructor is discouraged. - const CooldownType(int value) : super(value); + const CooldownType(super.value); /// Combine two cooldown types. /// /// For details on how cooldown types are combined, see [CooldownCheck.getKey]. - CooldownType operator |(CooldownType other) => CooldownType(value | other.value); - - /// Return whether [check] applies to [instance]. - /// - /// A type is considered to *apply* to another if all the values making up that type are also - /// present in the other type. - /// - /// For example, `CooldownType.user` applies to `CooldownType.user | CooldownType.guild` while - /// `CooldownType.channel` does not. - static bool applies(CooldownType instance, CooldownType check) => - instance.value & check.value == check.value; + @override + CooldownType operator |(Flags other) => CooldownType(value | other.value); @override String toString() { List components = []; - Map names = { + Map, String> names = { category: 'Category', channel: 'Channel', command: 'Command', @@ -78,10 +69,8 @@ class CooldownType extends IEnum { user: 'User', }; - for (final key in names.keys) { - if (applies(this, key)) { - components.add(names[key]!); - } + for (final flag in this) { + components.add(names[flag]!); } return 'CooldownType[${components.join(', ')}]'; @@ -156,7 +145,7 @@ class CooldownCheck extends AbstractCheck { /// /// [tokensPer] is optional and defaults to one, meaning a bucket can execute one before it is /// considered "on cooldown" for a given bucket. - CooldownCheck(this.type, this.duration, [this.tokensPer = 1, String? name]) + CooldownCheck(this.type, this.duration, {this.tokensPer = 1, String? name}) : super(name ?? 'Cooldown Check on $type'); /// The number of times a bucket can execute commands before this check fails. @@ -166,7 +155,7 @@ class CooldownCheck extends AbstractCheck { Duration duration; /// The cooldown type, used to sort contexts into buckets. - final CooldownType type; + final Flags type; Map _currentBucket = {}; Map _previousBucket = {}; @@ -174,7 +163,7 @@ class CooldownCheck extends AbstractCheck { late DateTime _currentStart = DateTime.now(); @override - FutureOr check(IContext context) { + FutureOr check(CommandContext context) { if (DateTime.now().isAfter(_currentStart.add(duration))) { _previousBucket = _currentBucket; _currentBucket = {}; @@ -215,47 +204,66 @@ class CooldownCheck extends AbstractCheck { /// /// You might also be interested in: /// - [type], which determines which values from [context] are combined to create a key. - int getKey(IContext context) { + // TODO: Move away from [int] in order to reduce the risk of hash collisions. + int getKey(CommandContext context) { List keys = []; - if (CooldownType.applies(type, CooldownType.category)) { + if (type.has(CooldownType.category)) { if (context.guild != null) { - keys.add((context.channel as IGuildChannel).parentChannel?.id.id ?? context.channel.id.id); + keys.add((context.channel as GuildChannel).parentId?.value ?? context.channel.id.value); } else { - keys.add(context.channel.id.id); + keys.add(context.channel.id.value); } } - if (CooldownType.applies(type, CooldownType.channel)) { - keys.add(context.channel.id.id); + if (type.has(CooldownType.channel)) { + keys.add(context.channel.id.value); } - if (CooldownType.applies(type, CooldownType.command)) { + if (type.has(CooldownType.command)) { keys.add(context.command.hashCode); } - if (CooldownType.applies(type, CooldownType.global)) { + if (type.has(CooldownType.global)) { keys.add(0); } if (type.value & CooldownType.guild.value != 0) { - keys.add(context.guild?.id.id ?? context.user.id.id); + keys.add(context.guild?.id.value ?? context.user.id.value); } - if (CooldownType.applies(type, CooldownType.role)) { + if (type.has(CooldownType.role)) { if (context.member != null) { - if (context.member!.roles.isNotEmpty) { - keys.add(PermissionsUtils.getMemberHighestRole(context.member!).id.id); - } else { - keys.add(context.guild!.everyoneRole.id.id); - } + keys.add( + context.member!.roles + .fold( + null, + (previousValue, element) { + final cached = element.manager.cache[element.id]; + + // TODO: Need to fetch if not cached + if (cached == null) { + return previousValue; + } + + if (previousValue == null) { + return cached; + } + + return previousValue.position > cached.position ? previousValue : cached; + }, + ) + ?.id + .value ?? + context.guild!.id.value, + ); } else { - keys.add(context.user.id.id); + keys.add(context.user.id.value); } } - if (CooldownType.applies(type, CooldownType.user)) { - keys.add(context.user.id.id); + if (type.has(CooldownType.user)) { + keys.add(context.user.id.value); } return Object.hashAll(keys); @@ -267,7 +275,7 @@ class CooldownCheck extends AbstractCheck { /// /// You might also be interested in: /// - [getKey], for getting the ID of the bucket the context was sorted into. - Duration remaining(IContext context) { + Duration remaining(CommandContext context) { if (check(context) as bool) { return Duration.zero; } @@ -291,7 +299,7 @@ class CooldownCheck extends AbstractCheck { } @override - late Iterable preCallHooks = [ + late Iterable preCallHooks = [ (context) { int key = getKey(context); @@ -306,11 +314,11 @@ class CooldownCheck extends AbstractCheck { ]; @override - Iterable get postCallHooks => []; + Iterable get postCallHooks => []; @override bool get allowsDm => true; @override - int? get requiredPermissions => null; + Flags? get requiredPermissions => null; } diff --git a/lib/src/checks/guild.dart b/lib/src/checks/guild.dart index f6ba7f5..03db50e 100644 --- a/lib/src/checks/guild.dart +++ b/lib/src/checks/guild.dart @@ -1,17 +1,3 @@ -// 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 'checks.dart'; @@ -41,50 +27,59 @@ class GuildCheck extends Check { /// Create a [GuildCheck] that succeeds if the context originated in [guild]. /// /// You might also be interested in: - /// - [GuildCheck.id], for creating this same check without an instance of [IGuild]; + /// - [GuildCheck.id], for creating this same check without an instance of [Guild]; /// - [GuildCheck.any], for checking if the context originated in any of a set of guilds. - GuildCheck(IGuild guild, [String? name]) : this.id(guild.id, name); + GuildCheck(Guild guild, {String? name}) : this.id(guild.id, name: name); /// Create a [GuildCheck] that succeeds if the ID of the guild the context originated in is [id]. - GuildCheck.id(Snowflake id, [String? name]) + GuildCheck.id(Snowflake id, {String? name}) : guildIds = [id], - super((context) => context.guild?.id == id, name ?? 'Guild Check on $id', false); + super( + (context) => context.guild?.id == id, + name: name ?? 'Guild Check on $id', + allowsDm: false, + ); /// Create a [GuildCheck] that succeeds if the context originated outside of a guild (generally, /// in private messages). /// /// You might also be interested in: /// - [GuildCheck.all], for checking that a context originated in a guild. - GuildCheck.none([String? name]) + GuildCheck.none({String? name}) : guildIds = [], - super((context) => context.guild == null, name ?? 'Guild Check on ', true, 0); + super( + (context) => context.guild == null, + name: name ?? 'Guild Check on ', + allowsDm: true, + requiredPermissions: Permissions(0), + ); /// Create a [GuildCheck] that succeeds if the context originated in a guild. /// /// You might also be interested in: /// - [GuildCheck.none], for checking that a context originated outside a guild. - GuildCheck.all([String? name]) + GuildCheck.all({String? name}) : guildIds = [null], super( (context) => context.guild != null, - name ?? 'Guild Check on ', - false, + name: name ?? 'Guild Check on ', + allowsDm: false, ); /// Create a [GuildCheck] that succeeds if the context originated in any of [guilds]. /// /// You might also be interested in: - /// - [GuildCheck.anyId], for creating the same check without instances of [IGuild]. - GuildCheck.any(Iterable guilds, [String? name]) - : this.anyId(guilds.map((guild) => guild.id), name); + /// - [GuildCheck.anyId], for creating the same check without instances of [Guild]. + GuildCheck.any(Iterable guilds, {String? name}) + : this.anyId(guilds.map((guild) => guild.id), name: name); /// Create a [GuildCheck] that succeeds if the id of the guild the context originated in is in /// [ids]. - GuildCheck.anyId(Iterable ids, [String? name]) + GuildCheck.anyId(Iterable ids, {String? name}) : guildIds = ids, super( (context) => ids.contains(context.guild?.id), - name ?? 'Guild Check on any of [${ids.join(', ')}]', - false, + name: name ?? 'Guild Check on any of [${ids.join(', ')}]', + allowsDm: false, ); } diff --git a/lib/src/checks/permissions.dart b/lib/src/checks/permissions.dart index ab9a6e8..4c2388d 100644 --- a/lib/src/checks/permissions.dart +++ b/lib/src/checks/permissions.dart @@ -1,22 +1,8 @@ -// 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_commands/src/commands/interfaces.dart'; -import 'package:nyxx_commands/src/context/interaction_context.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx_commands/src/util/util.dart'; +import '../commands/interfaces.dart'; +import '../context/base.dart'; import 'checks.dart'; /// A check that succeeds if the member invoking the command has a certain set of permissions. @@ -28,59 +14,66 @@ class PermissionsCheck extends Check { /// The bitfield representing the permissions required by this check. /// /// You might also be interested in: - /// - [PermissionsConstants], for computing the value for this field; + /// - [Permissions], for computing the value for this field; /// - [AbstractCheck.requiredPermissions], for setting permissions on any check. - // TODO: Rename to `permissions` once AbstractCheck.permissions is removed - final int permissionsValue; + final Flags permissions; /// Whether this check should allow server administrators to configure overrides that allow /// specific users or channels to execute this command regardless of permissions. final bool allowsOverrides; /// Whether this check requires the user invoking the command to have all of the permissions in - /// [permissionsValue] or only a single permission from [permissionsValue]. + /// [permissions] or only a single permission from [permissions]. /// /// If this is true, the member invoking the command must have all the permissions in - /// [permissionsValue] to execute the command. Otherwise, members need only have one of the - /// permissions in [permissionsValue] to execute the command. + /// [permissions] to execute the command. Otherwise, members need only have one of the + /// permissions in [permissions] to execute the command. final bool requiresAll; /// Create a new [PermissionsCheck]. PermissionsCheck( - this.permissionsValue, { + this.permissions, { this.allowsOverrides = true, this.requiresAll = false, String? name, - bool allowsDm = true, + super.allowsDm = true, }) : super( + name: name ?? 'Permissions check on $permissions', + requiredPermissions: permissions, (context) async { - IMember? member = context.member; + Member? member = context.member; if (member == null) { return allowsDm; } - IPermissions effectivePermissions = - await (context.channel as IGuildChannel).effectivePermissions(member); + final effectivePermissions = await computePermissions( + context.guild!, + context.channel as GuildChannel, + member, + ); if (allowsOverrides) { - ISlashCommand command; + ApplicationCommand command; - if (context is IInteractionContext) { - command = context.interactionEvent.interactions.commands - .firstWhere((command) => command.id == context.interaction.commandId); + if (context is InteractionCommandContextData) { + command = context.commands.registeredCommands.singleWhere( + (element) => + element.id == (context as InteractionCommandContextData).interaction.data.id, + ); } else { // If the invocation was not from a slash command, try to find a matching slash // command and use the overrides from that. - ICommandRegisterable root = context.command; + CommandRegisterable root = context.command; - while (root.parent is ICommandRegisterable) { - root = root.parent as ICommandRegisterable; + while (root.parent is CommandRegisterable) { + root = root.parent as CommandRegisterable; } - Iterable matchingCommands = - context.commands.interactions.commands.where( - (command) => command.name == root.name && command.type == SlashCommandType.chat, + Iterable matchingCommands = + context.commands.registeredCommands.where( + (command) => + command.name == root.name && command.type == ApplicationCommandType.chatInput, ); if (matchingCommands.isEmpty) { @@ -90,13 +83,12 @@ class PermissionsCheck extends Check { command = matchingCommands.first; } - ISlashCommandPermissionOverrides overrides = - await command.getPermissionOverridesInGuild(context.guild!.id).getOrDownload(); + CommandPermissions overrides = await command.fetchPermissions(context.guild!.id); - if (overrides.permissionOverrides.isEmpty) { - overrides = await context.commands.interactions - .getGlobalOverridesInGuild(context.guild!.id) - .getOrDownload(); + if (overrides.permissions.isEmpty) { + overrides = + (await context.client.guilds[context.guild!.id].commands.listPermissions()) + .singleWhere((overrides) => overrides.command == null); } bool? def; @@ -107,15 +99,15 @@ class PermissionsCheck extends Check { int highestRoleIndex = -1; - for (final override in overrides.permissionOverrides) { - if (override.isEveryone) { - def = override.allowed; - } else if (override.isAllChannels) { - channelDef = override.allowed; - } else if (override.type == SlashCommandPermissionType.channel && + for (final override in overrides.permissions) { + if (override.id == context.guild!.id) { + def = override.hasPermission; + } else if (override.id == Snowflake(context.guild!.id.value - 1)) { + channelDef = override.hasPermission; + } else if (override.type == CommandPermissionType.channel && override.id == context.channel.id) { - channel = override.allowed; - } else if (override.type == SlashCommandPermissionType.role) { + channel = override.hasPermission; + } else if (override.type == CommandPermissionType.role) { int roleIndex = -1; int i = 0; @@ -129,35 +121,32 @@ class PermissionsCheck extends Check { } if (highestRoleIndex < roleIndex) { - role = override.allowed; + role = override.hasPermission; highestRoleIndex = roleIndex; } - } else if (override.type == SlashCommandPermissionType.user && + } else if (override.type == CommandPermissionType.user && override.id == context.user.id) { - user = override.allowed; + user = override.hasPermission; // No need to continue if we found an override for the specific user break; } } - Iterable prioritised = [def, channelDef, role, channel, user].whereType(); + Iterable prioritized = [def, channelDef, role, channel, user].whereType(); - if (prioritised.isNotEmpty) { - return prioritised.last; + if (prioritized.isNotEmpty) { + return prioritized.last; } } - int corresponding = effectivePermissions.raw & permissionsValue; + Flags corresponding = effectivePermissions & permissions; if (requiresAll) { - return corresponding == permissionsValue; + return corresponding == permissions; } return corresponding != 0; }, - name ?? 'Permissions check on $permissionsValue', - allowsDm, - permissionsValue, ); /// Create a [PermissionsCheck] that allows nobody to execute a command, unless configured @@ -166,5 +155,5 @@ class PermissionsCheck extends Check { bool allowsOverrides = true, String? name, bool allowsDm = true, - }) : this(0, allowsOverrides: allowsOverrides, allowsDm: allowsDm, name: name); + }) : this(const Permissions(0), allowsOverrides: allowsOverrides, allowsDm: allowsDm, name: name); } diff --git a/lib/src/checks/user.dart b/lib/src/checks/user.dart index 9a7e3d4..609d2a5 100644 --- a/lib/src/checks/user.dart +++ b/lib/src/checks/user.dart @@ -1,17 +1,3 @@ -// 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 'checks.dart'; @@ -24,29 +10,32 @@ class UserCheck extends Check { /// Create a new [UserCheck] that succeeds if the context was created by [user]. /// /// You might also be interested in: - /// - [UserCheck.id], for creating this same check without an instance of [IUser], + /// - [UserCheck.id], for creating this same check without an instance of [User], /// - [UserCheck.any], for checking that a context was created by a user in a set or users. - UserCheck(IUser user, [String? name]) : this.id(user.id, name); + UserCheck(User user, {String? name}) : this.id(user.id, name: name); /// Create a new [UserCheck] that succeeds if the ID of the user that created the context is [id]. - UserCheck.id(Snowflake id, [String? name]) + UserCheck.id(Snowflake id, {String? name}) : userIds = [id], - super((context) => context.user.id == id, name ?? 'User Check on $id'); + super( + name: name ?? 'User Check on $id', + (context) => context.user.id == id, + ); /// Create a new [UserCheck] that succeeds if the context was created by any one of [users]. /// /// You might also be interested in: - /// - [UserCheck.anyId], for creating this same check without instance of [IUser]. - UserCheck.any(Iterable users, [String? name]) - : this.anyId(users.map((user) => user.id), name); + /// - [UserCheck.anyId], for creating this same check without instance of [User]. + UserCheck.any(Iterable users, {String? name}) + : this.anyId(users.map((user) => user.id), name: name); /// Create a new [UserCheck] that succeeds if the ID of the user that created the context is in /// [ids]. - UserCheck.anyId(Iterable ids, [String? name]) + UserCheck.anyId(Iterable ids, {String? name}) : userIds = ids, super( + name: name ?? 'User Check on any of [${ids.join(', ')}]', (context) => ids.contains(context.user.id), - name ?? 'User Check on any of [${ids.join(', ')}]', ); } @@ -58,33 +47,33 @@ class RoleCheck extends Check { /// Create a new [RoleCheck] that succeeds if the user that created the context has [role]. /// /// You might also be interested in: - /// - [RoleCheck.id], for creating this same check without an instance of [IRole]; + /// - [RoleCheck.id], for creating this same check without an instance of [Role]; /// - [RoleCheck.any], for checking that the user that created a context has one of a set or /// roles. - RoleCheck(IRole role, [String? name]) : this.id(role.id, name); + RoleCheck(Role role, {String? name}) : this.id(role.id, name: name); /// Create a new [RoleCheck] that succeeds if the user that created the context has a role with /// the id [id]. - RoleCheck.id(Snowflake id, [String? name]) + RoleCheck.id(Snowflake id, {String? name}) : roleIds = [id], super( + name: name ?? 'Role Check on $id', (context) => context.member?.roles.any((role) => role.id == id) ?? false, - name ?? 'Role Check on $id', ); /// Create a new [RoleCheck] that succeeds if the user that created the context has any of [roles]. /// /// You might also be interested in: - /// - [RoleCheck.anyId], for creating this same check without instances of [IRole]. - RoleCheck.any(Iterable roles, [String? name]) - : this.anyId(roles.map((role) => role.id), name); + /// - [RoleCheck.anyId], for creating this same check without instances of [Role]. + RoleCheck.any(Iterable roles, {String? name}) + : this.anyId(roles.map((role) => role.id), name: name); /// Create a new [RoleCheck] that succeeds if the user that created the context has any role for /// which the role's id is in [roles]. - RoleCheck.anyId(Iterable roles, [String? name]) + RoleCheck.anyId(Iterable roles, {String? name}) : roleIds = roles, super( + name: name ?? 'Role Check on any of [${roles.join(', ')}]', (context) => context.member?.roles.any((role) => roles.contains(role.id)) ?? false, - name ?? 'Role Check on any of [${roles.join(', ')}]', ); } diff --git a/lib/src/commands.dart b/lib/src/commands.dart index 16e07c3..cd62c03 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -1,22 +1,6 @@ -// 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 'dart:async'; -import 'package:logging/logging.dart'; import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; import 'checks/checks.dart'; import 'checks/guild.dart'; @@ -24,13 +8,13 @@ import 'commands/chat_command.dart'; 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 'context/base.dart'; +import 'context/context_manager.dart'; +import 'converters/combine.dart'; import 'converters/converter.dart'; +import 'converters/fallback.dart'; import 'errors.dart'; +import 'event_manager.dart'; import 'mirror_utils/mirror_utils.dart'; import 'options.dart'; import 'util/util.dart'; @@ -40,28 +24,25 @@ final Logger logger = Logger('Commands'); /// The base plugin used to interact with nyxx_commands. /// -/// Since nyxx 3.0.0, classes can extend [BasePlugin] and be registered as plugins to an existing -/// nyxx client by calling [INyxx.registerPlugin]. nyxx_commands uses that interface, which avoids -/// the need for a seperate wrapper class. -/// /// Commands can be added to nyxx_commands with the [addCommand] method. Once you've added the /// [CommandsPlugin] to your nyxx client, these commands will automatically become available once /// the client is ready. /// -/// The [CommandsPlugin] will automatically subscribe to all the event streams it needs, as well as -/// create its own instance of [IInteractions] for using slash commands. If you want to access this -/// instance for your own use, it is available through the [interactions] getter. +/// The [CommandsPlugin] will automatically subscribe to all the event streams it needs. It will +/// also bulk override all globally registered slash commands and guild commands in the guilds where +/// commands with [GuildCheck]s are registered. /// /// For example, here is how you would create and register [CommandsPlugin]: /// ```dart -/// INyxxWebsocket client = NyxxFactory.createNyxxWebsocket(...); -/// -/// CommandsPlugin commands = CommandsPlugin( +/// final commands = CommandsPlugin( /// prefix: (_) => '!', /// ); /// -/// client.registerPlugin(commands); -/// client.connect(); +/// final client = await Nyxx.connectGateway( +/// token, +/// intents, +/// options: GatewayClientOptions(plugins: [commands]), +/// ); /// ``` /// /// [CommandsPlugin] is also where [Converter]s are managed and stored. New developers need not @@ -74,10 +55,13 @@ final Logger logger = Logger('Commands'); /// - [addCommand], for adding commands to your bot; /// - [check], for adding checks to your bot; /// - [MessageCommand] and [UserCommand], for creating Message and User Commands respectively. -class CommandsPlugin extends BasePlugin implements ICommandGroup { +class CommandsPlugin extends NyxxPlugin implements CommandGroup { /// A function called to determine the prefix for a specific message. /// - /// This function should return a [String] representing the prefix to use for a given message. + /// This function should return a [Pattern] that should match the start of the message content if + /// it begins with the prefix. + /// + /// If this function is `null`, message commands are disabled. /// /// For example, for a prefix of `!`: /// ```dart @@ -86,18 +70,18 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { /// /// Or, for either `!` or `$` as a prefix: /// ```dart - /// (message) => message.content.startsWith('!') ? '!' : '$' + /// (_) => RegExp(r'!|\$') /// ``` /// /// You might also be interested in: /// - [dmOr], which allows for commands in private messages to omit the prefix; /// - [mentionOr], which allows for commands to be executed with the client's mention (ping). - final String Function(IMessage) prefix; + final FutureOr Function(MessageCreateEvent)? prefix; final StreamController _onCommandErrorController = StreamController.broadcast(); - final StreamController _onPreCallController = StreamController.broadcast(); - final StreamController _onPostCallController = StreamController.broadcast(); + final StreamController _onPreCallController = StreamController.broadcast(); + final StreamController _onPostCallController = StreamController.broadcast(); /// A stream of [CommandsException]s that occur during a command's execution. /// @@ -111,32 +95,22 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { /// Exceptions thrown from within a command will be wrapped in an [UncaughtException], allowing /// you to access the context in which a command was thrown. /// - /// By default, nyxx_commands logs all exceptions added to this stream. This behaviour can be + /// By default, nyxx_commands logs all exceptions added to this stream. This behavior can be /// changed in [options]. /// /// You might also be interested in: /// - [CommandsException], the class all exceptions in nyxx_commands subclass; - /// - [ICallHooked.onPostCall], a stream that emits [IContext]s once a command completes + /// - [CallHooked.onPostCall], a stream that emits [CommandContext]s once a command completes /// successfully. late final Stream onCommandError = _onCommandErrorController.stream; @override - late final Stream onPreCall = _onPreCallController.stream; + late final Stream onPreCall = _onPreCallController.stream; @override - late final Stream onPostCall = _onPostCallController.stream; + late final Stream onPostCall = _onPostCallController.stream; - final Map> _converters = {}; - - /// The [IInteractions] instance used by this [CommandsPlugin]. - /// - /// [IInteractions] is the backend for the [Discord Application Command API](https://discord.com/developers/docs/interactions/application-commands) - /// and is used by nyxx_commands to register and handle slash commands. - /// - /// Because [IInteractions] also allows you to use [Message Components](https://discord.com/developers/docs/interactions/message-components), - /// developers might need to use this instance of [IInteractions]. It is not recommended to create - /// your own instance alongside nyxx_commands as that might result in commands being deleted. - late final IInteractions interactions; + final Map, Converter> _converters = {}; @override final CommandsOptions options; @@ -152,29 +126,32 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { /// to. Snowflake? guild; - /// The client this [CommandsPlugin] instance is attached to. - /// - /// Will be `null` if the plugin has not been added to a client. - /// - /// You might also be interested in: - /// - [INyxx.registerPlugin], for adding plugins to clients. - INyxx? client; + /// The [ContextManager] attached to this [CommandsPlugin]. + late final ContextManager contextManager = ContextManager(this); + + /// The [EventManager] attached to this [CommandsPlugin]. + late final EventManager eventManager = EventManager(this); @override final List checks = []; final Map _userCommands = {}; final Map _messageCommands = {}; - final Map _chatCommands = {}; + final Map _chatCommands = {}; @override - Iterable get children => + Iterable get children => {..._userCommands.values, ..._messageCommands.values, ..._chatCommands.values}; + @override + String get name => 'Commands'; + + /// A list of commands registered by this [CommandsPlugin] to the Discord API. + final List registeredCommands = []; + + final Set _attachedClients = {}; + /// Create a new [CommandsPlugin]. - /// - /// Note that the plugin must then be added to a nyxx client with [INyxx.registerPlugin] before it - /// can be used. CommandsPlugin({ required this.prefix, this.guild, @@ -183,482 +160,215 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { registerDefaultConverters(this); if (options.logErrors) { - onCommandError.listen((error) { - logger - ..warning('Uncaught exception in command') - ..shout(error); - }); + onCommandError.listen( + (error) => logger.shout('Uncaught exception in command', error, error.stackTrace), + ); } } @override - void onRegister(INyxx nyxx, Logger logger) async { - client = nyxx; - - if (nyxx is INyxxWebsocket) { - nyxx.eventsWs.onMessageReceived.listen((event) => _processMessage(event.message)); - - interactions = IInteractions.create(options.backend ?? WebsocketInteractionBackend(nyxx)); - } else { - logger.warning('Commands was not intended for use without NyxxWebsocket.'); - - throw CommandsError( - 'Cannot create the Interactions backend for non-websocket INyxx instances.'); - } - - if (nyxx.ready) { - await _syncWithInteractions(); - } else { - nyxx.onReady.listen((event) async { - await _syncWithInteractions(); - }); - } - } - - @override - Future onBotStop(INyxx nyxx, Logger logger) async { - await _onPostCallController.close(); - await _onPreCallController.close(); - await _onCommandErrorController.close(); - } - - Future _syncWithInteractions() async { - for (final builder in await _getSlashBuilders()) { - interactions.registerSlashCommand(builder); - } - - interactions.sync( - syncRule: ManualCommandSync(sync: client?.options.shardIds?.contains(0) ?? true)); - } - - Future _processMessage(IMessage message) async { - try { - String prefix = this.prefix(message); - StringView view = StringView(message.content); - - if (view.skipString(prefix)) { - IChatContext context = await _messageChatContext(message, view, prefix); - - if (message.author.bot && !context.command.resolvedOptions.acceptBotCommands!) { - return; + Future afterConnect(NyxxGateway client) async { + _attachedClients.add(client); + + client.onMessageComponentInteraction + .map((event) => event.interaction) + .where((interaction) => interaction.data.type == MessageComponentType.button) + .listen( + (interaction) async { + try { + await eventManager.processButtonInteraction(interaction); + } on CommandsException catch (e) { + _onCommandErrorController.add(e); } + }, + ); - if (message.author.id == (client as INyxxRest).self.id && - !context.command.resolvedOptions.acceptSelfCommands!) { - return; + client.onMessageComponentInteraction + .map((event) => event.interaction) + .where((interaction) => interaction.data.type == MessageComponentType.stringSelect) + .listen( + (interaction) async { + try { + await eventManager.processSelectMenuInteraction(interaction); + } on CommandsException catch (e) { + _onCommandErrorController.add(e); } + }, + ); - logger.fine('Invoking command ${context.command.name} from message $message'); - - await context.command.invoke(context); + client.onMessageCreate.listen((event) async { + try { + await eventManager.processMessageCreateEvent(event); + } on CommandsException catch (e) { + _onCommandErrorController.add(e); } - } on CommandsException catch (e) { - _onCommandErrorController.add(e); - } - } + }); - Future _processChatInteraction( - ISlashCommandInteractionEvent interactionEvent, - ChatCommand command, - ) async { - try { - IChatContext context = await _interactionChatContext(interactionEvent, command); - - if (context.command.resolvedOptions.autoAcknowledgeInteractions!) { - Duration latency = Duration.zero; - if (client is INyxxWebsocket) { - latency = (client as INyxxWebsocket).shardManager.gatewayLatency; - } - - Duration timeout = Duration(seconds: 3) - latency * 2; + client.onApplicationCommandInteraction.map((event) => event.interaction).listen( + (interaction) async { + try { + final applicationCommand = registeredCommands.singleWhere( + (command) => command.id == interaction.data.id, + ); - Timer(timeout, () async { - try { - await interactionEvent.acknowledge( - hidden: context.command.resolvedOptions.hideOriginalResponse!, + if (interaction.data.type == ApplicationCommandType.user) { + await eventManager.processUserInteraction( + interaction, + _userCommands[applicationCommand.name]!, ); - } on AlreadyRespondedError { - // ignore: command has responded itself - } - }); - } - - logger.fine('Invoking command ${context.command.name} ' - 'from interaction ${interactionEvent.interaction.token}'); - - await context.command.invoke(context); - } on CommandsException catch (e) { - _onCommandErrorController.add(e); - } - } - - Future _processUserInteraction( - ISlashCommandInteractionEvent interactionEvent, UserCommand command) async { - try { - UserContext context = await _interactionUserContext(interactionEvent, command); - - if (options.autoAcknowledgeInteractions) { - Timer(Duration(seconds: 2), () async { - try { - await interactionEvent.acknowledge( - hidden: options.hideOriginalResponse, + } else if (interaction.data.type == ApplicationCommandType.message) { + await eventManager.processMessageInteraction( + interaction, + _messageCommands[applicationCommand.name]!, ); - } on AlreadyRespondedError { - // ignore: command has responded itself - } - }); - } - - logger.fine('Invoking command ${context.command.name} ' - 'from interaction ${interactionEvent.interaction.token}'); - - await context.command.invoke(context); - } on CommandsException catch (e) { - _onCommandErrorController.add(e); - } - } - - Future _processMessageInteraction( - ISlashCommandInteractionEvent interactionEvent, MessageCommand command) async { - try { - MessageContext context = await _interactionMessageContext(interactionEvent, command); + } else if (interaction.data.type == ApplicationCommandType.chatInput) { + final (command, options) = _resolveChatCommand(interaction, applicationCommand); - if (options.autoAcknowledgeInteractions) { - Timer(Duration(seconds: 2), () async { - try { - await interactionEvent.acknowledge( - hidden: options.hideOriginalResponse, + await eventManager.processChatInteraction( + interaction, + options, + command, ); - } on AlreadyRespondedError { - // ignore: command has responded itself } - }); - } - - logger.fine('Invoking command ${context.command.name} ' - 'from interaction ${interactionEvent.interaction.token}'); - - await context.command.invoke(context); - } on CommandsException catch (e) { - _onCommandErrorController.add(e); - } - } - - Future _processAutocompleteInteraction( - IAutocompleteInteractionEvent interactionEvent, - FutureOr?> Function(AutocompleteContext) callback, - ChatCommand command, - ) async { - try { - AutocompleteContext context = await _autocompleteContext(interactionEvent, command); + } on CommandsException catch (e) { + _onCommandErrorController.add(e); + } + }, + ); + client.onApplicationCommandAutocompleteInteraction + .map((event) => event.interaction) + .listen((interaction) async { try { - Iterable? choices = await callback(context); + final applicationCommand = registeredCommands.singleWhere( + (command) => command.id == interaction.data.id, + ); - if (choices != null) { - interactionEvent.respond(choices.toList()); - } - } on Exception catch (e) { - throw AutocompleteFailedException(e, context); - } - } on CommandsException catch (e) { - _onCommandErrorController.add(e); - } - } + final (command, options) = _resolveChatCommand(interaction, applicationCommand); - Future _messageChatContext( - IMessage message, StringView contentView, String prefix) async { - ChatCommand command = getCommand(contentView) ?? (throw CommandNotFoundException(contentView)); + final functionData = loadFunctionData(command.execute); + final focusedOption = options.singleWhere((element) => element.isFocused == true); + final focusedParameter = functionData.parametersData + .singleWhere((element) => element.name == focusedOption.name); - ITextChannel channel = await message.channel.getOrDownload(); + final converter = focusedParameter.converterOverride ?? getConverter(focusedParameter.type); - IGuild? guild; - IMember? member; - IUser user; - if (message.guild != null) { - guild = await message.guild!.getOrDownload(); + await eventManager.processAutocompleteInteraction( + interaction, + (focusedParameter.autocompleteOverride ?? converter?.autocompleteCallback)!, + command, + ); + } on CommandsException catch (e) { + _onCommandErrorController.add(e); + } + }); - member = message.member; - user = await member!.user.getOrDownload(); - } else { - user = message.author as IUser; + if (children.isNotEmpty) { + _syncCommands(client); } - - return MessageChatContext( - commands: this, - guild: guild, - channel: channel, - member: member, - user: user, - command: command, - client: client!, - prefix: prefix, - message: message, - rawArguments: contentView.remaining, - ); } - Future _interactionChatContext( - ISlashCommandInteractionEvent interactionEvent, ChatCommand command) async { - ISlashCommandInteraction interaction = interactionEvent.interaction; + (ChatCommand, List) _resolveChatCommand( + Interaction interaction, + ApplicationCommand applicationCommand, + ) { + List options = interaction.data.options ?? []; + ChatCommandComponent command = _chatCommands[applicationCommand.name]!; - IMember? member = interaction.memberAuthor; - IUser user; - if (member != null) { - user = await member.user.getOrDownload(); - } else { - user = interaction.userAuthor!; - } + while (command is! ChatCommand) { + assert(options.isNotEmpty); - Map rawArguments = {}; + final subcommandOption = options.single; - for (final option in interactionEvent.args) { - rawArguments[option.name] = option.value; + options = subcommandOption.options ?? []; + command = command.children.singleWhere((element) => element.name == subcommandOption.name); } - return InteractionChatContext( - commands: this, - guild: await interaction.guild?.getOrDownload(), - channel: await interaction.channel.getOrDownload(), - member: member, - user: user, - command: command, - client: client!, - interaction: interaction, - rawArguments: rawArguments, - interactionEvent: interactionEvent, - ); + return (command, options); } - Future _interactionUserContext( - ISlashCommandInteractionEvent interactionEvent, UserCommand command) async { - ISlashCommandInteraction interaction = interactionEvent.interaction; - - IMember? member = interaction.memberAuthor; - IUser user; - if (member != null) { - user = await member.user.getOrDownload(); - } else { - user = interaction.userAuthor!; - } - - IUser targetUser = client!.users[interaction.targetId] ?? - await client!.httpEndpoints.fetchUser(interaction.targetId!); - - IGuild? guild = await interaction.guild?.getOrDownload(); - - return UserContext( - commands: this, - client: client!, - interactionEvent: interactionEvent, - interaction: interaction, - command: command, - channel: await interaction.channel.getOrDownload(), - member: member, - user: user, - guild: guild, - targetUser: targetUser, - targetMember: guild?.members[targetUser.id] ?? await guild?.fetchMember(targetUser.id), - ); + @override + void beforeClose(NyxxGateway client) { + registeredCommands.removeWhere((command) => command.manager.client == client); + _attachedClients.remove(client); } - Future _interactionMessageContext( - ISlashCommandInteractionEvent interactionEvent, MessageCommand command) async { - ISlashCommandInteraction interaction = interactionEvent.interaction; + Future _syncCommands(NyxxGateway client) async { + final builders = await _buildCommands(); - IMember? member = interaction.memberAuthor; - IUser user; - if (member != null) { - user = await member.user.getOrDownload(); - } else { - user = interaction.userAuthor!; - } + final commands = await Future.wait(builders.entries.map( + (e) => e.key == null + ? client.commands.bulkOverride(e.value) + : client.guilds[e.key!].commands.bulkOverride(e.value), + )); - IGuild? guild = await interaction.guild?.getOrDownload(); - - return MessageContext( - commands: this, - client: client!, - interactionEvent: interactionEvent, - interaction: interaction, - command: command, - channel: await interaction.channel.getOrDownload(), - member: member, - user: user, - guild: guild, - targetMessage: interaction.channel.getFromCache()!.messageCache[interaction.targetId] ?? - await interaction.channel.getFromCache()!.fetchMessage(interaction.targetId!), - ); - } - - 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!; - } + registeredCommands.addAll(commands.expand((_) => _)); - 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(), - ); + logger.info('Synced ${builders.values.fold(0, (p, e) => p + e.length)} commands to Discord'); } - Future> _getSlashBuilders() async { - List builders = []; + Future>> _buildCommands() async { + final result = >{null: []}; for (final command in children) { - if (!_shouldGenerateBuildersFor(command)) { + final shouldRegister = command is! ChatCommandComponent || + command.hasSlashCommand || + (command is ChatCommand && command.resolvedOptions.type != CommandType.textOnly); + if (!shouldRegister) { continue; } - AbstractCheck allChecks = Check.all(command.checks); - - Iterable guildChecks = command.checks.whereType(); - - if (guildChecks.length > 1) { - throw Exception('Cannot have more than one Guild Check per Command'); + final checks = Check.all(command.checks); + + final ApplicationCommandType type; + final String? description; + final Map? localizedDescriptions; + final List? options; + + switch (command) { + case ChatCommandComponent(): + type = ApplicationCommandType.chatInput; + description = command.description; + localizedDescriptions = command.localizedDescriptions; + options = command.getOptions(this); + case MessageCommand(): + type = ApplicationCommandType.message; + description = null; + localizedDescriptions = null; + options = null; + case UserCommand(): + type = ApplicationCommandType.user; + description = null; + localizedDescriptions = null; + options = null; + case _: + throw CommandsError('Unknown command type ${command.runtimeType}'); } - Iterable guildIds = guildChecks.isNotEmpty ? guildChecks.first.guildIds : [null]; - - for (final guildId in guildIds) { - if (command is IChatCommandComponent) { - SlashCommandBuilder builder = SlashCommandBuilder( - command.name, - command.description, - List.of( - _processHandlerRegistration(command.getOptions(this), command), - ), - canBeUsedInDm: await allChecks.allowsDm, - requiredPermissions: await allChecks.requiredPermissions, - guild: guildId ?? guild, - type: SlashCommandType.chat, - localizationsName: command.localizedNames, - localizationsDescription: command.localizedDescriptions, - ); + final builder = ApplicationCommandBuilder( + type: type, + name: command.name, + nameLocalizations: command.localizedNames, + description: description, + descriptionLocalizations: localizedDescriptions, + options: options, + defaultMemberPermissions: await checks.requiredPermissions, + hasDmPermission: await checks.allowsDm, + ); - if (command is ChatCommand && command.resolvedType != CommandType.textOnly) { - builder.registerHandler((interaction) => _processChatInteraction(interaction, command)); + final guildChecks = command.checks.whereType(); - _processAutocompleteHandlerRegistration(builder.options, command); - } - - builders.add(builder); - } else if (command is UserCommand) { - SlashCommandBuilder builder = SlashCommandBuilder( - command.name, - null, - [], - canBeUsedInDm: await allChecks.allowsDm, - requiredPermissions: await allChecks.requiredPermissions, - guild: guildId ?? guild, - type: SlashCommandType.user, - localizationsName: command.localizedNames, - ); - - builder.registerHandler((interaction) => _processUserInteraction(interaction, command)); - - builders.add(builder); - } else if (command is MessageCommand) { - SlashCommandBuilder builder = SlashCommandBuilder( - command.name, - null, - [], - canBeUsedInDm: await allChecks.allowsDm, - requiredPermissions: await allChecks.requiredPermissions, - guild: guildId ?? guild, - type: SlashCommandType.message, - localizationsName: command.localizedNames, - ); - - builder - .registerHandler((interaction) => _processMessageInteraction(interaction, command)); - - builders.add(builder); - } + if (guildChecks.length > 1) { + throw CommandsError('Cannot have more than one GuildCheck per command'); } - } - return builders; - } - - bool _shouldGenerateBuildersFor(ICommandRegisterable child) { - if (child is IChatCommandComponent) { - if (child.hasSlashCommand) { - return true; + final guilds = guildChecks.singleOrNull?.guildIds ?? [null]; + for (final id in guilds) { + (result[id] ??= []).add(builder); } - - return child is ChatCommand && child.type != CommandType.textOnly; } - return true; - } - - Iterable _processHandlerRegistration( - Iterable options, - IChatCommandComponent current, - ) { - for (final builder in options) { - if (builder.type == CommandOptionType.subCommand) { - 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!, - current.children.where((child) => child.name == builder.name).first - as IChatCommandComponent, - ); - } - } - return options; - } - - void _processAutocompleteHandlerRegistration( - Iterable options, - ChatCommand command, - ) { - Iterator builderIterator = options.iterator; - - Iterable parameters = loadFunctionData(command.execute) - .parametersData - // Skip context parameter - .skip(1); - - Iterator parameterIterator = parameters.iterator; - - while (builderIterator.moveNext() && parameterIterator.moveNext()) { - Converter? converter = parameterIterator.current.converterOverride ?? - getConverter(parameterIterator.current.type); - - FutureOr?> Function(AutocompleteContext)? autocompleteCallback = - parameterIterator.current.autocompleteOverride ?? converter?.autocompleteCallback; - - if (autocompleteCallback != null) { - builderIterator.current.registerAutocompleteHandler( - (event) => _processAutocompleteInteraction(event, autocompleteCallback, command)); - } - } + return result; } /// Adds a converter to this [CommandsPlugin]. @@ -673,7 +383,17 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { /// - [registerDefaultConverters], for adding the default converters to a [CommandsPlugin]; /// - [getConverter], for retrieving the [Converter] for a specific type. void addConverter(Converter converter) { - _converters[T] = converter; + RuntimeType type = converter.output; + + // If we were given a type argument, use that as the target type. + // We're guaranteed by type safety that [converter] will be a subtype + // of Converter, so we can assume that the provided type argument + // is compatible with the converter. + if (T != dynamic) { + type = RuntimeType(); + } + + _converters[type] = converter; } /// Gets a [Converter] for a specific type. @@ -685,47 +405,68 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { /// /// You might also be interested in: /// - [addConverter], for adding converters to this [CommandsPlugin]. - Converter? getConverter(Type type, {bool logWarn = true}) { + Converter? getConverter(RuntimeType type, {bool logWarn = true}) { if (_converters.containsKey(type)) { - return _converters[type]!; + return _converters[type]! as Converter; } - List> assignable = []; - List> superClasses = []; + List> assignable = []; + List> superTypes = []; for (final key in _converters.keys) { - if (isAssignableTo(key, type)) { - assignable.add(_converters[key]!); - } else if (isAssignableTo(type, key)) { - superClasses.add(_converters[key]!); + if (key.isSubtypeOf(type)) { + assignable.add(_converters[key]! as Converter); + } else if (key.isSupertypeOf(type)) { + superTypes.add(_converters[key]!); } } - for (final converter in superClasses) { + for (final converter in superTypes) { // Converters for types that superclass the target type might return an instance of the // target type. assignable.add(CombineConverter(converter, (superInstance, context) { - if (isAssignableTo(superInstance.runtimeType, type)) { - return superInstance; + if (superInstance.isOfType(type)) { + return superInstance as T; } + return null; })); } if (assignable.isNotEmpty) { if (logWarn) { - logger.warning('Using assembled converter for type $type. If this is intentional, you ' - 'should register a custom converter for that type using ' - '`addConverter(getConverter($type, logWarn: false) as Converter<$type>)`'); + logger.warning( + 'Using assembled converter for type ${type.internalType}. If this is intentional, you ' + 'should register a custom converter for that type using ' + '`addConverter(getConverter(RuntimeType<${type.internalType}>(), logWarn: false))`', + ); } return FallbackConverter(assignable); } return null; } + bool _scheduledSync = false; + @override - void addCommand(ICommandRegisterable command) { - if (command is IChatCommandComponent) { + void addCommand(CommandRegisterable command) { + if (_attachedClients.isNotEmpty && !_scheduledSync) { + _scheduledSync = true; + scheduleMicrotask(() { + logger.warning( + 'Registering commands after bot is ready might trigger rate limits when syncing commands', + ); + _attachedClients.forEach(_syncCommands); + _scheduledSync = false; + }); + } + + command.parent = this; + + command.onPreCall.listen(_onPreCallController.add); + command.onPostCall.listen(_onPostCallController.add); + + if (command is ChatCommandComponent) { if (_chatCommands.containsKey(command.name)) { throw CommandRegistrationError('Command with name "${command.name}" already exists'); } @@ -736,14 +477,12 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { } } - command.parent = this; - _chatCommands[command.name] = command; for (final alias in command.aliases) { _chatCommands[alias] = command; } - for (final child in command.walkCommands() as Iterable) { + for (final child in command.walkCommands()) { logger.info('Registered command "${child.fullName}"'); } } else if (command is UserCommand) { @@ -753,8 +492,6 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { _userCommands[command.name] = command; - command.parent = this; - logger.info('Registered User Command "${command.name}"'); } else if (command is MessageCommand) { if (_messageCommands.containsKey(command.name)) { @@ -764,49 +501,17 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { _messageCommands[command.name] = command; - command.parent = this; - logger.info('Registered Message Command "${command.name}"'); } else { logger.warning('Unknown command type "${command.runtimeType}"'); } - - command.onPreCall.listen(_onPreCallController.add); - command.onPostCall.listen(_onPostCallController.add); - - if (client?.ready ?? false) { - logger.warning('Registering commands after bot is ready might cause global commands to be ' - 'deleted'); - _syncWithInteractions(); - } } @override - ChatCommand? getCommand(StringView view) { - String name = view.getWord(); - - if (_chatCommands.containsKey(name)) { - IChatCommandComponent child = _chatCommands[name]!; - - if (child is ChatCommand && child.resolvedType != CommandType.slashOnly) { - ChatCommand? found = child.getCommand(view); - - if (found == null) { - return child; - } - - return found; - } else { - return child.getCommand(view) as ChatCommand?; - } - } - - view.undo(); - return null; - } + ChatCommand? getCommand(StringView view) => getCommandHelper(view, _chatCommands); @override - Iterable walkCommands() sync* { + Iterable walkCommands() sync* { yield* _userCommands.values; yield* _messageCommands.values; diff --git a/lib/src/commands/chat_command.dart b/lib/src/commands/chat_command.dart index 6ab8439..25f60e8 100644 --- a/lib/src/commands/chat_command.dart +++ b/lib/src/commands/chat_command.dart @@ -1,25 +1,10 @@ -// 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 'dart:async'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx/nyxx.dart'; import '../checks/checks.dart'; import '../commands.dart'; import '../context/chat_context.dart'; -import '../context/context.dart'; import '../converters/converter.dart'; import '../errors.dart'; import '../mirror_utils/mirror_utils.dart'; @@ -57,35 +42,22 @@ 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 { - final StreamController _onPreCallController = StreamController.broadcast(); - final StreamController _onPostCallController = StreamController.broadcast(); +mixin ChatGroupMixin implements ChatCommandComponent { + final StreamController _onPreCallController = StreamController.broadcast(); + final StreamController _onPostCallController = StreamController.broadcast(); @override - late final Stream onPreCall = _onPreCallController.stream; + late final Stream onPreCall = _onPreCallController.stream; @override - late final Stream onPostCall = _onPostCallController.stream; + late final Stream onPostCall = _onPostCallController.stream; - final Map _childrenMap = {}; + final Map _childrenMap = {}; @override - void addCommand(ICommandRegisterable command) { - if (command is! IChatCommandComponent) { - throw CommandsError( - 'All child commands of chat groups or commands must implement IChatCommandComponent'); - } - + void addCommand(ChatCommandComponent command) { if (_childrenMap.containsKey(command.name)) { throw CommandRegistrationError( 'Command with name "$fullName ${command.name}" already exists'); @@ -114,7 +86,7 @@ mixin ChatGroupMixin implements IChatCommandComponent { } @override - Iterable get children => Set.of(_childrenMap.values); + Iterable get children => Set.of(_childrenMap.values); @override Iterable walkCommands() sync* { @@ -123,69 +95,50 @@ mixin ChatGroupMixin implements IChatCommandComponent { } for (final child in children) { - yield* child.walkCommands() as Iterable; + yield* child.walkCommands(); } } @override - ChatCommand? getCommand(StringView view) { - String name = view.getWord(); - - if (_childrenMap.containsKey(name)) { - IChatCommandComponent child = _childrenMap[name]!; - - if (child is ChatCommand && child.resolvedType != CommandType.slashOnly) { - ChatCommand? found = child.getCommand(view); - - if (found == null) { - return child; - } - - return found; - } else { - return child.getCommand(view) as ChatCommand?; - } - } - - view.undo(); - return null; - } + ChatCommand? getCommand(StringView view) => getCommandHelper(view, _childrenMap); @override String get fullName => - (parent == null || parent is! ICommandRegisterable - ? '' - : '${(parent as ICommandRegisterable).name} ') + + (parent is! ChatCommandComponent ? '' : '${(parent as ChatCommandComponent).fullName} ') + name; @override bool get hasSlashCommand => children.any((child) => - (child is ChatCommand && child.resolvedType != CommandType.textOnly) || + (child is ChatCommand && child.resolvedOptions.type != CommandType.textOnly) || child.hasSlashCommand); @override - Iterable getOptions(CommandsPlugin commands) { + List getOptions(CommandsPlugin commands) { List options = []; for (final child in children) { if (child.hasSlashCommand) { - options.add(CommandOptionBuilder( - CommandOptionType.subCommandGroup, - child.name, - child.description, - options: List.of(child.getOptions(commands)), - localizationsName: child.localizedNames, - localizationsDescription: child.localizedDescriptions, - )); - } else if (child is ChatCommand && child.resolvedType != CommandType.textOnly) { - options.add(CommandOptionBuilder( - CommandOptionType.subCommand, - child.name, - child.description, - options: List.of(child.getOptions(commands)), - localizationsName: child.localizedNames, - localizationsDescription: child.localizedDescriptions, - )); + options.add( + CommandOptionBuilder( + type: CommandOptionType.subCommandGroup, + name: child.name, + nameLocalizations: child.localizedNames, + description: child.description, + descriptionLocalizations: child.localizedDescriptions, + options: List.of(child.getOptions(commands)), + ), + ); + } else if (child is ChatCommand && child.resolvedOptions.type != CommandType.textOnly) { + options.add( + CommandOptionBuilder( + type: CommandOptionType.subCommand, + name: child.name, + nameLocalizations: child.localizedNames, + description: child.description, + descriptionLocalizations: child.localizedDescriptions, + options: List.of(child.getOptions(commands)), + ), + ); } } @@ -195,8 +148,8 @@ mixin ChatGroupMixin implements IChatCommandComponent { /// Represents a [Subcommand Group](https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups). /// -/// [ChatGroup]s can be used to organise chat commands into groups of similar commands to avoid -/// filling up a user's UI. Instead, commands are organised into a tree, with only the root of the +/// [ChatGroup]s can be used to organize chat commands into groups of similar commands to avoid +/// filling up a user's UI. Instead, commands are organized into a tree, with only the root of the /// tree being shown to the user until they select it. /// /// You might also be interested in: @@ -204,10 +157,10 @@ mixin ChatGroupMixin implements IChatCommandComponent { class ChatGroup with ChatGroupMixin, - ParentMixin, - CheckMixin, - OptionsMixin - implements IChatCommandComponent { + ParentMixin, + CheckMixin, + OptionsMixin + implements ChatCommandComponent { @override final List aliases; @@ -231,7 +184,7 @@ class ChatGroup this.name, this.description, { this.aliases = const [], - Iterable children = const [], + Iterable children = const [], Iterable checks = const [], this.options = const CommandOptions(), this.localizedNames, @@ -280,10 +233,10 @@ class ChatGroup class ChatCommand with ChatGroupMixin, - ParentMixin, - CheckMixin, - OptionsMixin - implements ICommand, IChatCommandComponent { + ParentMixin, + CheckMixin, + OptionsMixin + implements Command, ChatCommandComponent { @override final String name; @@ -293,32 +246,6 @@ class ChatCommand @override final String description; - /// The type of this [ChatCommand]. - /// - /// The type of a [ChatCommand] influences how it can be invoked and can be used to make chat - /// 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 @@ -332,9 +259,9 @@ class ChatCommand /// [CommandsPlugin.onCommandError], wrapped in an [UncaughtException]. /// /// You might also be interested in: - /// - [Name], for explicitely setting an argument's name; + /// - [Name], for explicitly setting an argument's name; /// - [Description], for adding descriptions to arguments; - /// - [Choices], for specifiying the choices for an argument; + /// - [Choices], for specifying the choices for an argument; /// - [UseConverter], for overriding the [Converter] used for a specific argument. @override final Function execute; @@ -352,7 +279,7 @@ class ChatCommand final List singleChecks = []; /// The types of the required and positional arguments of [execute], in the order they appear. - final List argumentTypes = []; + final List> argumentTypes = []; @override final CommandOptions options; @@ -368,93 +295,15 @@ class ChatCommand /// Create a new [ChatCommand]. /// /// You might also be interested in: - /// - [ChatCommand.slashOnly], for creating [ChatCommand]s with type [CommandType.slashOnly]; - /// - [ChatCommand.textOnly], for creating [ChatCommand]s with type [CommandType.textOnly]. + /// - [MessageCommand], for creating message commands; + /// - [UserCommand], for creating user commands; + /// - [CommandOptions.type], for changing how a command can be executed. ChatCommand( - String name, - String description, - Function execute, { - List aliases = const [], - CommandType type = CommandType.def, - Iterable children = const [], - Iterable checks = const [], - Iterable singleChecks = const [], - CommandOptions options = const CommandOptions(), - Map? localizedNames, - Map? localizedDescriptions, - }) : this._( - name, - description, - execute, - IChatContext, - aliases: aliases, - type: type, - children: children, - checks: checks, - singleChecks: singleChecks, - options: options, - localizedNames: localizedNames, - localizedDescriptions: localizedDescriptions, - ); - - /// Create a new [ChatCommand] with type [CommandType.textOnly]. - ChatCommand.textOnly( - String name, - String description, - Function execute, { - List aliases = const [], - Iterable children = const [], - Iterable checks = const [], - Iterable singleChecks = const [], - CommandOptions options = const CommandOptions(), - }) : this._( - name, - description, - execute, - MessageChatContext, - aliases: aliases, - type: CommandType.textOnly, - children: children, - checks: checks, - singleChecks: singleChecks, - options: options, - ); - - /// Create a new [ChatCommand] with type [CommandType.slashOnly]. - ChatCommand.slashOnly( - String name, - String description, - Function execute, { - List aliases = const [], - Iterable children = const [], - Iterable checks = const [], - Iterable singleChecks = const [], - CommandOptions options = const CommandOptions(), - Map? localizedNames, - Map? localizedDescriptions, - }) : this._( - name, - description, - execute, - InteractionChatContext, - aliases: aliases, - type: CommandType.slashOnly, - children: children, - checks: checks, - singleChecks: singleChecks, - options: options, - localizedNames: localizedNames, - localizedDescriptions: localizedDescriptions, - ); - - ChatCommand._( this.name, this.description, - this.execute, - Type contextType, { + this.execute, { this.aliases = const [], - this.type = CommandType.def, - Iterable children = const [], + Iterable children = const [], Iterable checks = const [], Iterable singleChecks = const [], this.options = const CommandOptions(), @@ -471,6 +320,12 @@ class ChatCommand throw CommandRegistrationError('Invalid localized name for command "$name".'); } + RuntimeType contextType = switch (resolvedOptions.type) { + CommandType.textOnly => const RuntimeType.allowingDynamic(), + CommandType.slashOnly => const RuntimeType.allowingDynamic(), + null || CommandType.all => const RuntimeType.allowingDynamic(), + }; + _loadArguments(execute, contextType); for (final child in children) { @@ -486,14 +341,14 @@ class ChatCommand } } - void _loadArguments(Function fn, Type contextType) { + void _loadArguments(Function fn, RuntimeType contextType) { _functionData = loadFunctionData(fn); if (_functionData.parametersData.isEmpty) { throw CommandRegistrationError('Command callback function must have a Context parameter'); } - if (!isAssignableTo(contextType, _functionData.parametersData.first.type)) { + if (!contextType.isSupertypeOf(_functionData.parametersData.first.type)) { throw CommandRegistrationError( 'The first parameter of a command callback must be of type $contextType'); } @@ -508,7 +363,7 @@ class ChatCommand } if (parameter.converterOverride != null) { - if (!isAssignableTo(parameter.converterOverride!.output, parameter.type)) { + if (!parameter.type.isSupertypeOf(parameter.converterOverride!.output)) { throw CommandRegistrationError('Invalid converter override'); } } @@ -518,11 +373,7 @@ class ChatCommand } @override - Future invoke(IContext context) async { - if (context is! IChatContext) { - return; - } - + Future invoke(ChatContext context) async { List arguments = []; if (context is MessageChatContext) { @@ -557,11 +408,6 @@ class ChatCommand dynamic rawArgument = context.rawArguments[kebabCaseName]!; - if (isAssignableTo(rawArgument.runtimeType, parameter.type)) { - arguments.add(rawArgument); - continue; - } - arguments.add(await parse( context.commands, context, @@ -584,35 +430,37 @@ class ChatCommand try { await Function.apply(execute, [context, ...context.arguments]); - } on Exception catch (e) { - throw UncaughtException(e, context); + } catch (e, s) { + Error.throwWithStackTrace(UncaughtException(e, context)..stackTrace = s, s); } _onPostCallController.add(context); } @override - Iterable getOptions(CommandsPlugin commands) { - if (resolvedType != CommandType.textOnly) { + List getOptions(CommandsPlugin commands) { + if (resolvedOptions.type != CommandType.textOnly) { List options = []; for (final parameter in _functionData.parametersData.skip(1)) { Converter? argumentConverter = parameter.converterOverride ?? commands.getConverter(parameter.type); - Iterable? choices = - parameter.choices?.entries.map((entry) => ArgChoiceBuilder(entry.key, entry.value)); + Iterable>? choices = parameter.choices?.entries + .map((entry) => CommandOptionChoiceBuilder(name: entry.key, value: entry.value)); choices ??= argumentConverter?.choices; CommandOptionBuilder builder = CommandOptionBuilder( - argumentConverter?.type ?? CommandOptionType.string, - convertToKebabCase(parameter.name), - parameter.description ?? 'No description provided', - required: !parameter.isOptional, + type: argumentConverter?.type ?? CommandOptionType.string, + name: convertToKebabCase(parameter.name), + nameLocalizations: parameter.localizedNames, + description: parameter.description ?? 'No description provided', + descriptionLocalizations: parameter.localizedDescriptions, + isRequired: !parameter.isOptional, choices: choices?.toList(), - localizationsName: parameter.localizedNames, - localizationsDescription: parameter.localizedDescriptions, + hasAutocomplete: + (parameter.autocompleteOverride ?? argumentConverter?.autocompleteCallback) != null, ); argumentConverter?.processOptionCallback?.call(builder); @@ -628,15 +476,15 @@ class ChatCommand } @override - void addCommand(ICommandRegisterable command) { - if (command is! IChatCommandComponent) { + void addCommand(CommandRegisterable command) { + if (command is! ChatCommandComponent) { throw CommandsError( 'All child commands of chat groups or commands must implement IChatCommandComponent'); } - if (resolvedType != CommandType.textOnly) { + if (resolvedOptions.type != CommandType.textOnly) { if (command.hasSlashCommand || - (command is ChatCommand && command.resolvedType != CommandType.textOnly)) { + (command is ChatCommand && command.resolvedOptions.type != CommandType.textOnly)) { throw CommandRegistrationError('Cannot nest Slash commands!'); } } diff --git a/lib/src/commands/interfaces.dart b/lib/src/commands/interfaces.dart index 45eda72..fa9977b 100644 --- a/lib/src/commands/interfaces.dart +++ b/lib/src/commands/interfaces.dart @@ -1,29 +1,16 @@ -// 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_interactions/nyxx_interactions.dart'; +import 'package:nyxx/nyxx.dart'; import '../checks/checks.dart'; import '../commands.dart'; +import '../commands/chat_command.dart'; +import '../context/base.dart'; import '../context/chat_context.dart'; -import '../context/context.dart'; import '../errors.dart'; import '../util/view.dart'; import 'options.dart'; /// Represents an entity which can handle command callback hooks. -abstract class ICallHooked { +abstract class CallHooked { /// A stream that emits contexts *before* the command callback is executed. /// /// This stream emits before the callback is executed, but after checks and argument parsing is @@ -43,7 +30,7 @@ abstract class ICallHooked { /// Represents an entity that can handle checks. /// /// See [AbstractCheck] for an explanation of checks. -abstract class IChecked { +abstract class Checked { /// The checks that should be applied to this entity. /// /// Check are inherited, so this will include checks from any parent entities. @@ -58,9 +45,9 @@ abstract class IChecked { /// Represents an entity that supports command options. /// -/// Command options can influence a command's behaviour and how it can be invoked. Options are +/// Command options can influence a command's behavior and how it can be invoked. Options are /// inherited. -abstract class IOptions { +abstract class Options { /// The options to use for this entity. CommandOptions get options; } @@ -68,9 +55,9 @@ abstract class IOptions { /// Represents an entity that can be added as a child to a command group. /// /// You might also be interested in: -/// - [ICommandGroup], the interface for groups that [ICommandRegisterable]s can be added to. -abstract class ICommandRegisterable - implements ICallHooked, IChecked, IOptions { +/// - [CommandGroup], the interface for groups that [CommandRegisterable]s can be added to. +abstract class CommandRegisterable + implements CallHooked, Checked, Options { /// The name of this child. /// /// Generally, this will have to obey [Discord's command naming restrictions](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming) @@ -82,15 +69,15 @@ abstract class ICommandRegisterable /// Once a parent is added to a group, that group is considered to be this child's parent and this /// child cannot be added to any more groups. Attempting to do so will result in a /// [CommandsError]. - ICommandGroup? get parent; + CommandGroup? get parent; /// Set the parent of this child. Should not be used unless you are implementing your own command /// group. - set parent(ICommandGroup? parent); + set parent(CommandGroup? parent); - /// Get the resolvec options for this child. + /// Get the resolved options for this child. /// - /// Since [ICommandRegisterable] implements [IOptions], any class implementing this interface can + /// Since [CommandRegisterable] implements [Options], any class implementing this interface can /// provide options. However, since options are designed to be inherited, this getter provides a /// quick way to access options merged with those of this child's parent, if any. /// @@ -120,24 +107,24 @@ abstract class ICommandRegisterable /// An entity capable of having multiple child entities. /// /// You might also be interested in: -/// - [ICommandRegisterable], the type that all children must implement; -/// - [ICommand], the executable command type. -abstract class ICommandGroup implements ICallHooked, IChecked, IOptions { +/// - [CommandRegisterable], the type that all children must implement; +/// - [Command], the executable command type. +abstract class CommandGroup implements CallHooked, Checked, Options { /// A list of all the children of this group - Iterable> get children; + Iterable> get children; - /// Returns an iterable that recursively iterates over all the [ICommand]s in this group. + /// Returns an iterable that recursively iterates over all the [Command]s in this group. /// - /// This will return all the [ICommand]s in this group, whether they be direct children or - /// children of children. If you want all the direct [ICommand] children, consider using + /// This will return all the [Command]s in this group, whether they be direct children or + /// children of children. If you want all the direct [Command] children, consider using /// `children.whereType()` instead. - Iterable> walkCommands(); + Iterable> walkCommands(); /// Add a command to this group. /// /// A command can be added to a group at most once; trying to do so will result in a /// [CommandsError] being thrown. - void addCommand(ICommandRegisterable command); + void addCommand(covariant CommandRegisterable command); /// Attempt to get a command from a string. /// @@ -149,7 +136,7 @@ abstract class ICommandGroup implements ICallHooked, IChe /// You might also be interested in: /// - [walkCommands], for iterating over all commands in this group; /// - [children], for iterating over the children of this group. - ICommand? getCommand(StringView view); + Command? getCommand(StringView view); } /// An entity capable of being invoked by users. @@ -157,7 +144,7 @@ abstract class ICommandGroup implements ICallHooked, IChe /// You might also be interested in: /// - [ChatCommand], [MessageCommand] and [UserCommand], the three types of commands nyxx_commands /// supports. -abstract class ICommand implements ICommandRegisterable { +abstract class Command implements CommandRegisterable { /// The function called to execute this command. /// /// If any exception occurs while calling this function, it will be caught and added to @@ -168,8 +155,8 @@ abstract class ICommand implements ICommandRegisterable { /// /// This method might throw uncaught [CommandsException]s and should be handled with care. Thrown /// exceptions will not be added to [CommandsPlugin.onCommandError] unless called from within a - /// "safe" context where uncuaght exceptions are caught anyways. - void invoke(T context); + /// "safe" context where uncaught exceptions are caught anyways. + Future invoke(T context); } /// An entity that is part of a chat command tree. @@ -177,8 +164,8 @@ abstract class ICommand implements ICommandRegisterable { /// You might also be interested in: /// - [ChatCommand] and [ChatGroup], the concrete implementations of elements in a chat command /// tree. -abstract class IChatCommandComponent - implements ICommandRegisterable, ICommandGroup { +abstract class ChatCommandComponent + implements CommandRegisterable, CommandGroup { /// The description of this entity. /// /// This must be a non-empty string less than 100 characters in length. @@ -242,5 +229,14 @@ abstract class IChatCommandComponent Map? get localizedDescriptions; /// Return the [CommandOptionBuilder]s that represent this entity for slash command registration. - Iterable getOptions(CommandsPlugin commands); + List getOptions(CommandsPlugin commands); + + @override + ChatCommand? getCommand(StringView view); + + @override + Iterable walkCommands(); + + @override + Iterable get children; } diff --git a/lib/src/commands/message_command.dart b/lib/src/commands/message_command.dart index b3f2840..5186609 100644 --- a/lib/src/commands/message_command.dart +++ b/lib/src/commands/message_command.dart @@ -1,23 +1,8 @@ -// 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 'dart:async'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx/nyxx.dart'; import '../checks/checks.dart'; -import '../context/context.dart'; import '../context/message_context.dart'; import '../errors.dart'; import '../util/mixins.dart'; @@ -52,7 +37,7 @@ import 'options.dart'; /// - [UserCommand], for creating user commands. class MessageCommand with ParentMixin, CheckMixin, OptionsMixin - implements ICommand { + implements Command { @override final String name; @@ -88,11 +73,7 @@ class MessageCommand } @override - Future invoke(IContext context) async { - if (context is! MessageContext) { - return; - } - + Future invoke(MessageContext context) async { for (final check in checks) { if (!await check.check(context)) { throw CheckFailedException(check, context); @@ -103,7 +84,7 @@ class MessageCommand try { await execute(context); - } on Exception catch (e) { + } catch (e) { throw UncaughtException(e, context); } diff --git a/lib/src/commands/options.dart b/lib/src/commands/options.dart index a8edeb0..3407dc9 100644 --- a/lib/src/commands/options.dart +++ b/lib/src/commands/options.dart @@ -1,23 +1,10 @@ -// 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_commands/src/commands/chat_command.dart'; +import '../context/base.dart'; +import 'chat_command.dart'; /// Options that modify how a command behaves. /// /// You might also be interested in: -/// - [IOptions], the interface for entities that support options; +/// - [Options], the interface for entities that support options; /// - [CommandsOptions], the settings for the entire nyxx_commands package. class CommandOptions { /// Whether to automatically acknowledge interactions before they expire. @@ -30,9 +17,20 @@ class CommandOptions { /// Setting this to false means that you must acknowledge the interaction yourself. /// /// You might also be interested in: - /// - [IInteractionContext.acknowledge], for manually acknowledging interactions. + /// - [autoAcknowledgeDuration], for setting the time after which interactions will be + /// acknowledged. + /// - [InteractionInteractiveContext.acknowledge], for manually acknowledging interactions. final bool? autoAcknowledgeInteractions; + /// The duration after which to automatically acknowledge interactions. + /// + /// Has no effect if [autoAcknowledgeInteractions] is `false`. + /// + /// If this is `null`, the timeout for interactions is calculated based on the bot's latency. On + /// unstable networks, this might result in some interactions not being acknowledged, in which + /// case setting this option might help. + final Duration? autoAcknowledgeDuration; + /// Whether to accept messages sent by bot accounts as possible commands. /// /// If this is set to false, then other bot users will not be able to execute commands from this @@ -54,25 +52,36 @@ class CommandOptions { /// command loops. final bool? acceptSelfCommands; - /// Whether to hide the response from other users when the command is invoked from an interaction. + /// The [ResponseLevel] to use in commands if not explicit. /// - /// This sets the EPHEMERAL flag on interactions responses when [IContext.respond] is used. + /// Defaults to [ResponseLevel.public]. + final ResponseLevel? defaultResponseLevel; + + /// The type of [ChatCommand]s that are children of this entity. /// - /// You might also be interested in: - /// - [IInteractionContext.respond], which can override this setting by setting the `hidden` flag. - final bool? hideOriginalResponse; + /// The type of a [ChatCommand] influences how it can be invoked and can be used to make chat + /// commands executable only through Slash Commands, or only through text messages. + final CommandType? type; - /// The default [CommandType] for [ChatCommand]s that are children of this entity. - final CommandType? defaultCommandType; + /// Whether command fetching should be case insensitive. + /// + /// If this is `true`, [ChatCommand]s may be invoked by users without the command name matching + /// the case of the input. + /// + /// You might also be interested in: + /// - [ChatCommandComponent.aliases], for invoking a single command from multiple names. + final bool? caseInsensitiveCommands; /// Create a set of command options. /// /// Options set to `null` will be inherited from the parent. const CommandOptions({ this.autoAcknowledgeInteractions, + this.autoAcknowledgeDuration, this.acceptBotCommands, this.acceptSelfCommands, - this.hideOriginalResponse, - this.defaultCommandType, + this.defaultResponseLevel, + this.type, + this.caseInsensitiveCommands, }); } diff --git a/lib/src/commands/user_command.dart b/lib/src/commands/user_command.dart index b13f4d8..f0ab968 100644 --- a/lib/src/commands/user_command.dart +++ b/lib/src/commands/user_command.dart @@ -1,23 +1,8 @@ -// 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 'dart:async'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx/nyxx.dart'; import '../checks/checks.dart'; -import '../context/context.dart'; import '../context/user_context.dart'; import '../errors.dart'; import '../util/mixins.dart'; @@ -48,7 +33,7 @@ import 'options.dart'; /// - [MessageCommand], for creating message commands. class UserCommand with ParentMixin, CheckMixin, OptionsMixin - implements ICommand { + implements Command { @override final String name; @@ -84,11 +69,7 @@ class UserCommand } @override - Future invoke(IContext context) async { - if (context is! UserContext) { - return; - } - + Future invoke(UserContext context) async { for (final check in checks) { if (!await check.check(context)) { throw CheckFailedException(check, context); @@ -99,7 +80,7 @@ class UserCommand try { await execute(context); - } on Exception catch (e) { + } catch (e) { throw UncaughtException(e, context); } diff --git a/lib/src/context/autocomplete_context.dart b/lib/src/context/autocomplete_context.dart index 6cf9a08..a1e5b1e 100644 --- a/lib/src/context/autocomplete_context.dart +++ b/lib/src/context/autocomplete_context.dart @@ -1,64 +1,30 @@ -// 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'; +import '../converters/converter.dart' as converters show parse; +import '../errors.dart'; +import '../mirror_utils/mirror_utils.dart'; +import '../util/view.dart'; +import 'base.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; - +class AutocompleteContext extends ContextBase implements InteractionContextData { @override - final IUser user; - - @override - final ChatCommand command; - - @override - final INyxx client; - - @override - final ISlashCommandInteraction interaction; - - @override - final IAutocompleteInteractionEvent interactionEvent; + final ApplicationCommandAutocompleteInteraction interaction; /// 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; + /// Other options might have already been filled in and are accessible through [interaction]. + final InteractionOption 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. + /// + /// You might also be interested in: + /// - [parse], for parsing the current value. final String currentValue; /// A map containing the arguments and the values that the user has inputted so far. @@ -68,7 +34,7 @@ class AutocompleteContext implements IContextBase, IInteractionContextBase { /// /// The values might contain partial data. late final Map existingArguments = Map.fromEntries( - interactionEvent.options.map((option) => MapEntry(option.name, option.value.toString())), + interaction.data.options!.map((option) => MapEntry(option.name, option.value.toString())), ); /// A map containing the arguments of [command] and their value, if the user has inputted a value @@ -79,30 +45,131 @@ class AutocompleteContext implements IContextBase, IInteractionContextBase { /// The values might contain partial data. late final Map arguments; + /// The command for which arguments are being auto-completed. + final ChatCommand command; + + late final FunctionData _functionData = loadFunctionData(command.execute); + /// 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, + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, }) { - ISlashCommand command = commands.interactions.commands.singleWhere( - (command) => command.id == interaction.commandId, + ApplicationCommand command = commands.registeredCommands.singleWhere( + (element) => element.id == interaction.data.id, ); arguments = Map.fromIterable( - command.options.map((option) => option.name), + command.options!.map((option) => option.name), value: (option) => existingArguments[option], ); } /// Whether the user has inputted a value for an argument with the name [name]. bool hasArgument(String name) => existingArguments.containsKey(name); + + /// Attempts to parse the current value of this context as a value of type `T`. + /// + /// If `T` is not a supertype of the type of the parameter in the command callback, an exception + /// is thrown. + /// + /// You might also be interested in: + /// - [parseNamed], for parsing an arbitrary option; + /// - [parseWithType], for parsing an argument with a given type. + Future parse() => parseNamed(option.name); + + /// Attempts to parse the value of the option [name] to a value of type `T`. + /// + /// If `T` is not a supertype of the type of the parameter [name] in the command callback, an + /// exception is thrown. + /// + /// If the user has not provided a value for the option [name], `null` is returned. + /// + /// If no parameter with the name [name] exists in the command callback, an exception is thrown. + /// + /// You might also be interested in: + /// - [parse], for parsing the current option. + Future parseNamed(String name) async { + ParameterData parameterData = _functionData.parametersData.singleWhere( + (element) => element.name == name, + orElse: () => throw CommandsException( + 'No option with name "$name" found in command ${command.fullName}', + ), + ); + + if (!RuntimeType().isSupertypeOf(parameterData.type)) { + throw CommandsException('Type $T is not a supertype of ${parameterData.type}'); + } + + String? value = arguments[parameterData.name]; + + if (value == null) { + return null; + } + + return converters + .parse( + commands, + this, + StringView(value, isRestBlock: true), + parameterData.type, + ) + .then((value) => value as T); + } + + /// Parses the first option in the command callback with type `T`. + /// + /// If the user has not yet provided a value for that option, `null` is returned. + /// + /// If no option of type `T` is found, an exception is thrown. + /// + /// You might also be interested in: + /// - [parseNamed], for parsing an option by name. + Future parseWithType() => parseAllWithType().firstWhere( + (element) => true, + orElse: () => throw CommandsException( + 'No parameter with type $T found in command ${command.fullName}', + ), + ); + + /// Finds all the arguments with a type of `T` in the command callback and parses them. + /// + /// The values are ordered as they appear in the command callback. + /// + /// If the user has not yet provided a value, `null` is added to the stream instead. + /// + /// You might also be interested in: + /// - [parseWithType], for parsing a single value with a given type. + Stream parseAllWithType() { + RuntimeType type = RuntimeType(); + + return Stream.fromIterable( + _functionData.parametersData.where((element) => element.type.isSubtypeOf(type)), + ).asyncMap( + (parameter) { + String? value = arguments[parameter.name]; + + if (value == null) { + return null; + } + + return converters + .parse( + commands, + this, + StringView(value, isRestBlock: true), + parameter.type, + ) + .then((value) => value as T); + }, + ); + } } diff --git a/lib/src/context/base.dart b/lib/src/context/base.dart new file mode 100644 index 0000000..98c2c4c --- /dev/null +++ b/lib/src/context/base.dart @@ -0,0 +1,419 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import '../commands.dart'; +import '../commands/interfaces.dart'; +import '../converters/converter.dart'; +import '../util/util.dart'; +import 'component_context.dart'; +import 'modal_context.dart'; + +/// The base class for all contexts in nyxx_commands. +/// +/// Contains data that all contexts provide. +/// +/// You might also be interested in: +/// - [CommandContextData], which contains data about contexts which execute a command. +abstract interface class ContextData { + /// The user that triggered this context's creation. + User get user; + + /// The member that triggered this context's created, or `null` if created outside of a guild. + Member? get member; + + /// The guild in which the context was created, or `null` if created outside of a guild. + Guild? get guild; + + /// The channel in which the context was created. + TextChannel get channel; + + /// The instance of [CommandsPlugin] which created this context. + CommandsPlugin get commands; + + /// The client that emitted the event triggering this context's creation. + NyxxGateway get client; +} + +/// Data about a context in which a command was executed. +/// +/// You might also be interested in: +/// - [CommandContext], which exposes the functionality for interacting with this context. +/// - [ContextData], the base class for all contexts. +abstract interface class CommandContextData implements ContextData { + /// The command that was executed or is being processed. + Command get command; +} + +/// A context that can be interacted with. +/// +/// You might also be interested in: +/// - [InteractionInteractiveContext], for contexts that originate from an interaction. +abstract interface class InteractiveContext { + /// The parent of this context. + /// + /// If this context was created by an operation on another context, this will be that context. + /// Otherwise, this is `null`. + /// + /// You might also be interested in: + /// - [awaitButtonPress], [awaitSelection] and [awaitMultiSelection], some of the methods that can + /// create a child context; + /// - [delegate], the context that has this context as its parent. + InteractiveContext? get parent; + + /// The delegate of this context. + /// + /// If this is set, most operations on this context will be forwarded to this context instead. + /// This prevents contexts from going stale when waiting for a user to interact and makes the + /// command flow more accurate in the Discord UI. + /// + /// You might also be interested in: + /// - [awaitButtonPress], [awaitSelection] and [awaitMultiSelection], some of the methods that can + /// create a context to delegate to. + /// - [parent], the context of which this context is the delegate. + InteractiveContext? get delegate; + + /// The youngest context that handles all interactions. + /// + /// This is the same as repeatedly accessing [delegate] until it returns `null`. + InteractiveContext get latestContext; + + /// Send a response to the command. + /// + /// [level] can be set to change how the response is set. If is is not passed, + /// [CommandOptions.defaultResponseLevel] is used instead. + /// + /// You might also be interested in: + /// - [InteractionInteractiveContext.acknowledge], for acknowledging interactions without + /// responding. + Future respond(MessageBuilder builder, {ResponseLevel? level}); + + /// Wait for a user to press a button and return a context representing that button press. + /// + /// You might also be interested in: + /// - [awaitSelection] and [awaitMultiSelection], for getting a selection from a user. + Future awaitButtonPress(ComponentId componentId); + + /// Wait for a user to select a single option from a multi-select menu and return a context + /// representing that selection. + /// + /// Will throw a [StateError] if more than one option is selected (for example, from a + /// multi-select menu allowing more than one choice). + Future> awaitSelection( + ComponentId componentId, { + Converter? converterOverride, + }); + + /// Wait for a user to select options from a multi-select menu and return a context + /// representing that selection. + Future>> awaitMultiSelection( + ComponentId componentId, { + Converter? converterOverride, + }); + + /// Wait for a user to press on any button on a given message and return a context representing + /// the button press. + /// + /// You might also be interested in: + /// - [awaitButtonPress], for getting a press from a single button; + /// - [getButtonSelection], for getting a value from a button selection; + /// - [getSelection], for getting a selection from a multi-select menu. + Future getButtonPress(Message message); + + /// Get a selection from a user, presenting the options as an array of buttons. + /// + /// If [styles] is set, the style of a button presenting a given option will depend on the value + /// set in the map. + /// + /// If [timeout] is set, this method will complete with an error after [timeout] has passed. + /// + /// If [authorOnly] is set, only the author of this interaction will be able to interact with a + /// button. + /// + /// [level] will change the level at which the message is sent, similarly to [respond]. + /// + /// [toButton] and [converterOverride] can be set to change how each value is converted to a + /// button. At most one of them may be set, and the default is to use [Converter.toButton] on the + /// default conversion for `T`. + /// + /// You might also be interested in: + /// - [getButtonPress], for getting a button press from any button on a message; + /// - [getSelection], for getting a selection from a multi-select menu; + /// - [getConfirmation], for getting a basic `true`/`false` selection from the user. + Future getButtonSelection( + List values, + MessageBuilder builder, { + Map? styles, + bool authorOnly = true, + ResponseLevel? level, + Duration? timeout, + FutureOr Function(T)? toButton, + Converter? converterOverride, + }); + + /// Present the user with two options and return whether the positive one was clicked. + /// + /// If [styles] is set, the style of a button presenting a given option will depend on the value + /// set in the map. [values] can also be set to change the text displayed on each button. + /// + /// If [timeout] is set, this method will complete with an error after [timeout] has passed. + /// + /// If [authorOnly] is set, only the author of this interaction will be able to interact with a + /// button. + /// + /// [level] will change the level at which the message is sent, similarly to [respond]. + Future getConfirmation( + MessageBuilder builder, { + Map values = const {true: 'Yes', false: 'No'}, + Map styles = const {true: ButtonStyle.success, false: ButtonStyle.danger}, + bool authorOnly = true, + ResponseLevel? level, + Duration? timeout, + }); + + /// Present the user with a drop-down menu of choices and return the selected choice. + /// + /// If [timeout] is set, this method will complete with an error after [timeout] has passed. + /// + /// If [authorOnly] is set, only the author of this interaction will be able to interact with a + /// button. + /// + /// [level] will change the level at which the message is sent, similarly to [respond]. + /// + /// [converterOverride] can be set to change how each value is converted to a multi-select option. + /// The default is to use [Converter.toSelectMenuOption] on the default converter for `T`. + /// + /// You might also be interested in: + /// - [getMultiSelection], for getting multiple selection; + /// - [getButtonSelection], for getting a selection from a button; + /// - [awaitSelection], for getting a selection from a pre-existing selection menu. + Future getSelection( + List choices, + MessageBuilder builder, { + ResponseLevel? level, + Duration? timeout, + bool authorOnly = true, + FutureOr Function(T)? toSelectMenuOption, + Converter? converterOverride, + }); + + /// Present the user with a drop-down menu of choices and return the selected choices. + /// + /// If [timeout] is set, this method will complete with an error after [timeout] has passed. + /// + /// If [authorOnly] is set, only the author of this interaction will be able to interact with a + /// button. + /// + /// [level] will change the level at which the message is sent, similarly to [respond]. + /// + /// [converterOverride] can be set to change how each value is converted to a multi-select option. + /// The default is to use [Converter.toSelectMenuOption] on the default converter for `T`. + /// + /// You might also be interested in: + /// - [getSelection], for getting a single selection; + /// - [getButtonSelection], for getting a selection from a button; + /// - [awaitSelection], for getting a selection from a pre-existing selection menu. + Future> getMultiSelection( + List choices, + MessageBuilder builder, { + ResponseLevel? level, + Duration? timeout, + bool authorOnly = true, + FutureOr Function(T)? toSelectMenuOption, + Converter? converterOverride, + }); +} + +/// A context that can be interacted with and originated from an interaction. +/// +/// You might also be interested in: +/// - [InteractionContextData], which contains data about interactions. +abstract interface class InteractionInteractiveContext implements InteractiveContext { + /// Acknowledge the underlying interaction without yet sending a response. + /// + /// [level] can be used to change whether the response should be hidden or not. + /// + /// You might also be interested in: + /// - [respond], for sending a full response. + Future acknowledge({ResponseLevel? level}); + + /// Wait for a user to submit a modal and return a context representing that submission. + /// + /// [customId] is the id of the modal to wait for. + /// + /// If [timeout] is set, this method will complete with an error after [timeout] has passed. + /// + /// You might also be interested in: + /// - [awaitSelection] and [awaitMultiSelection], for getting a selection from a user. + Future awaitModal(String customId, {Duration? timeout}); + + /// Present the user with a modal, wait for them to submit it, and return a context representing + /// that submission. + /// + /// [title] is the title of the modal that should be shown to the user. + /// + /// If [timeout] is set, this method will complete with an error after [timeout] has passed. + /// + /// [components] are the text inputs that will be presented to the user. The + /// [TextInputBuilder.customId] can be later used with [ModalContext.operator[]] to get the value + /// submitted by the user. + Future getModal({ + required String title, + required List components, + Duration? timeout, + }); +} + +/// 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. +/// +/// You might also be interested in: +/// - [CommandContextData], which exposes the data found in this context; +/// - [InteractionCommandContext], a context in which a command was executed from an interaction; +/// - [MessageChatContext], a context in which a command was executed from a text message. +abstract interface class CommandContext implements CommandContextData, InteractiveContext {} + +/// Data about a context which was created by an interaction. +/// +/// You might also be interested in: +/// - [InteractionCommandContextData], data about a context in which a command was executed from an +/// interaction; +/// - [ContextData], the base class for all contexts. +abstract interface class InteractionContextData implements ContextData { + /// The interaction that triggered this context's creation. + Interaction get interaction; +} + +/// Data about a context in which a command was executed from an interaction. +/// +/// You might also be interested in: +/// - [InteractionCommandContext], which exposes functionality for interacting with this context; +/// - [InteractionContextData], the base class for all contexts created from interactions. +abstract interface class InteractionCommandContextData implements InteractionContextData { + @override + ApplicationCommandInteraction get interaction; +} + +/// A context in which a command was executed from an interaction. +/// +/// Contains data about how and where the command was executed, and provides a simple interfaces for +/// responding to commands. +/// +/// You might also be interested in: +/// - [InteractionCommandContextData], which exposes the data found in this context, +/// - [CommandContext], the base class for all contexts representing a command execution. +abstract interface class InteractionCommandContext + implements InteractionCommandContextData, CommandContext, InteractionInteractiveContext {} + +/// Information about how a command should respond when using [InteractiveContext.respond]. +/// +/// This class mainly determines the properties of the message that is sent in response to a +/// command, such as whether it should be ephemeral or whether the user should be mentioned. +/// +/// You can create an instance of this class yourself, or use one of the provided levels: [private], +/// [hint], or [public]. +class ResponseLevel { + /// A private response. + /// + /// Interaction responses are hidden and message responses are sent via DMs. + static const private = ResponseLevel( + hideInteraction: true, + isDm: true, + mention: null, + preserveComponentMessages: true, + ); + + /// A response that follows how the user invoked the command. + /// + /// Interaction responses are hidden (as invoking a Slash Command is invisible to other users) and + /// message responses are shown in the channel. + static const hint = ResponseLevel( + hideInteraction: true, + isDm: false, + mention: null, + preserveComponentMessages: true, + ); + + /// A public responses. + /// + /// Both interaction and message responses are shown. + static const public = ResponseLevel( + hideInteraction: false, + isDm: false, + mention: null, + preserveComponentMessages: true, + ); + + /// Whether interaction responses sent at this level should be marked as ephemeral. + final bool hideInteraction; + + /// Whether message responses sent at this level should be sent via DM to the user. + final bool isDm; + + /// Whether message responses sent at this level should mention the user when replying to them. + /// + /// If set to `null`, inherits the behaviour of the message being sent, or the global allowed + /// mentions if the message builder does not set any. + final bool? mention; + + /// Whether to edit the message a component belongs to or create a new message when responding to + /// a component interaction. + final bool preserveComponentMessages; + + /// Construct a new response level. + /// + /// You might also be interested in: + /// - [private], [hint], and [public], pre-made levels for common use cases. + const ResponseLevel({ + required this.hideInteraction, + required this.isDm, + required this.mention, + required this.preserveComponentMessages, + }); + + /// Create a new [ResponseLevel] identical to this one with one or more fields changed. + /// + /// [mention] cannot be updated to be `null` due to a technical limitation with Dart. + // We'd need a senitel value to tell if an argument was actually passed to `mention` (to + // differentiate between `null` and nothing passed at all). While this is possible, it's overly + // verbose and complicated, so we just don't support it. + ResponseLevel copyWith({ + bool? hideInteraction, + bool? isDm, + bool? mention, + bool? preserveComponentMessages, + }) { + return ResponseLevel( + hideInteraction: hideInteraction ?? this.hideInteraction, + isDm: isDm ?? this.isDm, + mention: mention ?? this.mention, + preserveComponentMessages: preserveComponentMessages ?? this.preserveComponentMessages, + ); + } +} + +class ContextBase implements ContextData { + @override + final User user; + @override + final Member? member; + @override + final Guild? guild; + @override + final TextChannel channel; + @override + final CommandsPlugin commands; + @override + final NyxxGateway client; + + ContextBase({ + required this.user, + required this.member, + required this.guild, + required this.channel, + required this.commands, + required this.client, + }); +} diff --git a/lib/src/context/chat_context.dart b/lib/src/context/chat_context.dart index a2127da..e6109fc 100644 --- a/lib/src/context/chat_context.dart +++ b/lib/src/context/chat_context.dart @@ -1,57 +1,75 @@ -// 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/component_wrappers.dart'; -import '../context/context.dart'; -import '../context/interaction_context.dart'; +import '../util/mixins.dart'; +import 'base.dart'; -/// Represents a context in which a [ChatCommand] was invoked. +/// Data about a context in which a [ChatCommand] was executed. /// /// You might also be interested in: -/// - [MessageChatContext], for chat commands invoked from text messages; -/// - [InteractionChatContext], for chat commands invoked from slash commands. -abstract class IChatContext implements IContext { +/// - [ChatContext], which exposes functionality for interacting with this context; +/// - [CommandContext], the base class for all contexts representing a command execution. +abstract interface class ChatContextData implements CommandContext { + @override + ChatCommand get command; +} + +/// A context in which a [ChatCommand] was executed. +/// +/// Contains data about how and where the command was executed, and provides a simple interfaces for +/// responding to commands. +/// +/// You might also be interested in: +/// - [MessageChatContext], a context in which a [ChatCommand] was executed from a text message; +/// - [InteractionChatContext], a context in which a [ChatCommand] was executed from an interaction. +abstract interface class ChatContext implements ChatContextData, CommandContext { /// The arguments parsed from the user input. /// - /// The arguments are ordered by the order in which they appear in the function delcaration. Since + /// The arguments are ordered by the order in which they appear in the function declaration. Since /// slash commands can specify optional arguments in any order, optional arguments declared before /// the last provided argument will be set to their default value (or `null` if unspecified). /// /// You might also be interested in: /// - [ChatCommand.execute], the function that dictates the order in which arguments are provided; /// - [Converter], the means by which these arguments are parsed. - Iterable get arguments; + // Arguments are only initialized during command execution, so we put them here to avoid them + // being accessed before that. + List get arguments; /// Set the arguments used by this context. /// - /// Should not be used unless you are implementing your own commannd handler. - set arguments(Iterable value); + /// Should not be used unless you are implementing your own command handler. + set arguments(List value); +} +abstract class ChatContextBase extends ContextBase with InteractiveMixin implements ChatContext { @override - ChatCommand get command; + late final List arguments; + + @override + final ChatCommand command; + + ChatContextBase({ + required this.command, + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, + }); } -/// Represents a context in which a [ChatCommand] was invoked from a text message. +/// A context in which a [ChatCommand] was invoked from a text message. /// /// You might also be interested in: -/// - [InteractionChatContext], for chat commands invoked from slash commands. -class MessageChatContext with ComponentWrappersMixin implements IChatContext { +/// - [InteractionChatContext], a context in which a [ChatCommand] was executed from an interaction; +/// - [ChatContext], the base class for all context representing the execution of a [ChatCommand]. +class MessageChatContext extends ChatContextBase with MessageRespondMixin { + /// The message that triggered this command. + @override + final Message message; + /// The prefix that was used to invoke this command. /// /// You might also be interested in: @@ -59,9 +77,6 @@ class MessageChatContext with ComponentWrappersMixin implements IChatContext { /// message. final String prefix; - /// The message that triggered this command. - final IMessage message; - /// The unparsed arguments from the message. /// /// This is the content of the message stripped of the [prefix] and the full command name. @@ -70,128 +85,48 @@ class MessageChatContext with ComponentWrappersMixin implements IChatContext { /// - [arguments], for getting the parsed arguments from this context. final String rawArguments; - @override - late final Iterable arguments; - - @override - final ITextChannel channel; - - @override - final INyxx client; - - @override - final ChatCommand command; - - @override - final CommandsPlugin commands; - - @override - final IGuild? guild; - - @override - final IMember? member; - - @override - final IUser user; - /// Create a new [MessageChatContext]. MessageChatContext({ - required this.prefix, required this.message, + required this.prefix, required this.rawArguments, - required this.channel, - required this.client, - required this.command, - required this.commands, - required this.guild, - required this.member, - required this.user, + required super.command, + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, }); - - @override - Future respond(MessageBuilder builder, - {bool mention = true, bool private = false}) async { - if (private) { - return user.sendMessage(builder); - } else { - try { - return await channel.sendMessage(builder - ..replyBuilder = ReplyBuilder.fromMessage(message) - ..allowedMentions ??= (AllowedMentions() - ..allow( - reply: mention, - everyone: true, - roles: true, - users: true, - ))); - } on IHttpResponseError { - return channel.sendMessage(builder..replyBuilder = null); - } - } - } - - @override - String toString() => 'MessageContext[message=$message, message.content=${message.content}]'; } -/// Represents a context in which a [ChatCommand] was invoked from an interaction. +/// A context in which a [ChatCommand] was invoked from an interaction. /// /// You might also be interested in: -/// - [MessageChatContext], for chat commands invoked from text messages. -class InteractionChatContext - with InteractionContextMixin, ComponentWrappersMixin - implements IChatContext, IInteractionContext { +/// - [MessageChatContext], a context in which a [ChatCommand] was executed from a text message; +/// - [ChatContext], the base class for all context representing the execution of a [ChatCommand]. +class InteractionChatContext extends ChatContextBase + with InteractionRespondMixin + implements InteractionCommandContext { + @override + final ApplicationCommandInteraction interaction; + /// The unparsed arguments from the interaction. /// /// You might also be interested in: /// - [arguments], for getting the parsed arguments from this context. final Map rawArguments; - @override - late final Iterable arguments; - - @override - final ITextChannel channel; - - @override - final INyxx client; - - @override - final ChatCommand command; - - @override - final CommandsPlugin commands; - - @override - final IGuild? guild; - - @override - final ISlashCommandInteraction interaction; - - @override - final ISlashCommandInteractionEvent interactionEvent; - - @override - final IMember? member; - - @override - final IUser user; - /// Create a new [InteractionChatContext]. InteractionChatContext({ required this.rawArguments, - required this.channel, - required this.client, - required this.command, - required this.commands, - required this.guild, required this.interaction, - required this.interactionEvent, - required this.member, - required this.user, + required super.command, + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, }); - - @override - String toString() => - 'InteractionContext[interaction=${interaction.token}, arguments=$rawArguments]'; } diff --git a/lib/src/context/component_context.dart b/lib/src/context/component_context.dart new file mode 100644 index 0000000..420928a --- /dev/null +++ b/lib/src/context/component_context.dart @@ -0,0 +1,91 @@ +import 'package:nyxx_commands/src/util/util.dart'; +import 'package:nyxx/nyxx.dart'; + +import '../util/mixins.dart'; +import 'base.dart'; + +/// Data about a context in which a component was interacted with. +/// +/// You might also be interested in: +/// - [ComponentContext], which exposes the functionality for interacting with this context. +abstract class ComponentContextData implements InteractionContextData { + @override + MessageComponentInteraction get interaction; + + /// The ID of the component that was interacted with. + String get componentId; + + /// If [componentId] is a valid [ComponentId], this is the parsed version of that. + ComponentId? get parsedComponentId; +} + +/// A context in which a component was interacted with. +/// +/// Contains data about which component was interacted with and exposes functionality to respond to +/// that interaction. +/// +/// You might also be interested in: +/// - [ComponentContextData], which exposes the data found in this context. +abstract class ComponentContext implements ComponentContextData, InteractionInteractiveContext {} + +/// A context in which a button component was interacted with. +/// +/// You might also be interested in: +/// - [ComponentContext], the base class for all component contexts. +class ButtonComponentContext extends ContextBase + with InteractionRespondMixin, InteractiveMixin + implements ComponentContext { + @override + final MessageComponentInteraction interaction; + + @override + String get componentId => interaction.data.customId; + + @override + ComponentId? get parsedComponentId => ComponentId.parse(componentId); + + /// Create a new [ButtonComponentContext]. + ButtonComponentContext({ + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, + required this.interaction, + }); +} + +/// A context in which a multi-select component was interacted with. +/// +/// You might also be interested in: +/// - [ComponentContext], the base class for all component contexts. +class SelectMenuContext extends ContextBase + with InteractionRespondMixin, InteractiveMixin + implements ComponentContext { + @override + final MessageComponentInteraction interaction; + + @override + String get componentId => interaction.data.customId; + + /// The item selected by the user. + /// + /// Will be a [List] if multiple items were selected. + final T selected; + + @override + ComponentId? get parsedComponentId => ComponentId.parse(componentId); + + /// Create a new [SelectMenuContext]. + SelectMenuContext({ + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, + required this.interaction, + required this.selected, + }); +} diff --git a/lib/src/context/component_wrappers.dart b/lib/src/context/component_wrappers.dart deleted file mode 100644 index b8abd94..0000000 --- a/lib/src/context/component_wrappers.dart +++ /dev/null @@ -1,102 +0,0 @@ -// 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 'package:random_string/random_string.dart'; - -import 'context.dart'; - -mixin ComponentWrappersMixin implements IContext { - @override - Future getSelection(MultiselectBuilder selectionMenu, - {bool authorOnly = true, Duration? timeout = const Duration(minutes: 12)}) => - commands.interactions.events.onMultiselectEvent - .where((event) => event.interaction.customId == selectionMenu.customId) - .map((event) => event..acknowledge()) - .where( - (event) => - !authorOnly || - (event.interaction.memberAuthor ?? event.interaction.userAuthor as SnowflakeEntity) - .id == - user.id, - ) - .timeout( - timeout ?? Duration(), - onTimeout: timeout != null ? null : (sink) {}, - ) - .first; - - @override - Future getButtonPress(Iterable buttons, - {bool authorOnly = true, Duration? timeout = const Duration(minutes: 12)}) => - commands.interactions.events.onButtonEvent - .where((event) => - buttons.map((button) => button.customId).contains(event.interaction.customId)) - .map((event) => event..acknowledge()) - .where( - (event) => - !authorOnly || - (event.interaction.memberAuthor ?? event.interaction.userAuthor as SnowflakeEntity) - .id == - user.id, - ) - .timeout( - timeout ?? Duration(), - onTimeout: timeout != null ? null : (sink) {}, - ) - .first; - - @override - Future getConfirmation( - MessageBuilder message, { - bool authorOnly = true, - Duration? timeout = const Duration(minutes: 12), - String confirmMessage = 'Yes', - String denyMessage = 'No', - }) async { - ComponentMessageBuilder componentMessageBuilder = ComponentMessageBuilder() - ..allowedMentions = message.allowedMentions - ..attachments = message.attachments - ..content = message.content - ..embeds = message.embeds - ..files = message.files - ..replyBuilder = message.replyBuilder - ..tts = message.tts; - - if (message is ComponentMessageBuilder) { - componentMessageBuilder.componentRows = message.componentRows; - } else { - componentMessageBuilder.componentRows = []; - } - - ButtonBuilder confirmButton = - ButtonBuilder(confirmMessage, randomAlpha(10), ButtonStyle.success); - - ButtonBuilder denyButton = ButtonBuilder(denyMessage, randomAlpha(10), ButtonStyle.danger); - - componentMessageBuilder.addComponentRow(ComponentRowBuilder() - ..addComponent(confirmButton) - ..addComponent(denyButton)); - - await respond(componentMessageBuilder); - - IButtonInteractionEvent event = await getButtonPress( - [confirmButton, denyButton], - authorOnly: authorOnly, - timeout: timeout, - ); - return event.interaction.customId == confirmButton.customId; - } -} diff --git a/lib/src/context/context.dart b/lib/src/context/context.dart deleted file mode 100644 index a4d6136..0000000 --- a/lib/src/context/context.dart +++ /dev/null @@ -1,104 +0,0 @@ -// 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/interfaces.dart'; - -/// The base class for all contexts in nyxx_commands. -/// -/// 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 context was created, or `null` if created outside of a guild. - IGuild? get guild; - - /// The channel in which the context was created. - ITextChannel get channel; - - /// The member that triggered this context's created, or `null` if created outside of a guild. - IMember? get member; - - /// The user that triggered this context's creation. - IUser get user; - - /// The command that was executed or is being processed. - ICommand get 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 - /// invoked the command. In interactions, this is done by sending an ephemeral response, in text - /// commands this is handled by sending a Private Message to the user. - /// - /// You might also be interested in: - /// - [IInteractionContext.acknowledge], for acknowledging interactions without resopnding. - Future respond(MessageBuilder builder, {bool private = false}); - - /// Wait for a user to make a selection from a multiselect menu, then return the result of that - /// interaction. - /// - /// If [authorOnly] is `true`, only events triggered by the author of this context will be - /// returned, but other interactions will still be acknowledged. - /// - /// If [timeout] is set, this method will complete with an error after [timeout]. - Future getSelection(MultiselectBuilder selectionMenu, - {bool authorOnly = true, Duration? timeout = const Duration(minutes: 12)}); - - /// Wait for a user to press on a button, then return the result of that interaction. - /// - /// This method specifically listens for interactions on items of [buttons], ignoring other button - /// presses. - /// - /// If [authorOnly] is `true`, only events triggered by the author of this context will be - /// returned, but other interactions will still be acknowledged. - /// - /// If [timeout] is set, this method will complete with an error after [timeout]. - /// - /// You might also be interested in: - /// - [getConfirmation], a shortcut for getting user confirmation from buttons. - Future getButtonPress(Iterable buttons, - {bool authorOnly = true, Duration? timeout = const Duration(minutes: 12)}); - - /// Send a message prompting a user for confirmation, then return whether the user accepted the - /// choice. - /// - /// If [authorOnly] is `true`, only events triggered by the author of this context will be - /// returned, but other interactions will still be acknowledged. - /// - /// If [timeout] is set, this method will complete with an error after [timeout]. - /// - /// [confirmMessage] and [denyMessage] can be set to change the text displayed on the "confirm" - /// and "deny" buttons. - Future getConfirmation(MessageBuilder message, - {bool authorOnly = true, - Duration? timeout = const Duration(minutes: 12), - String confirmMessage = 'Yes', - String denyMessage = 'No'}); -} diff --git a/lib/src/context/context_manager.dart b/lib/src/context/context_manager.dart new file mode 100644 index 0000000..fbabfb6 --- /dev/null +++ b/lib/src/context/context_manager.dart @@ -0,0 +1,255 @@ +import 'package:nyxx/nyxx.dart'; + +import '../commands.dart'; +import '../commands/chat_command.dart'; +import '../commands/message_command.dart'; +import '../commands/user_command.dart'; +import '../errors.dart'; +import '../util/view.dart'; +import 'autocomplete_context.dart'; +import 'chat_context.dart'; +import 'component_context.dart'; +import 'message_context.dart'; +import 'modal_context.dart'; +import 'user_context.dart'; + +/// Exposes methods for creating contexts from the raw event dispatched from Discord. +/// +/// You do not need to create this class yourself; it is exposed through +/// [CommandsPlugin.contextManager]. +class ContextManager { + /// The [CommandsPlugin] this [ContextManager] is attached to. + /// + /// All contexts created by this [ContextManager] will include [commands] as + /// [ContextData.commands]. + /// + /// You might also be interested in: + /// - [ContextData.commands], the property which exposes the [CommandsPlugin] to commands. + final CommandsPlugin commands; + + /// Create a new [ContextManager] attached to a [CommandsPlugin]. + ContextManager(this.commands); + + /// Create a [MessageChatContext] from a [Message]. + /// + /// [message] is the message that triggered the command, [contentView] is a [StringView] of the + /// message's content with the prefix already skipped and [prefix] is the content of the match + /// that was skipped. + /// + /// Throws a [CommandNotFoundException] if [message] did not match any command on [commands]. + /// + /// You might also be interested in: + /// - [createInteractionChatContext], for creating [ChatContext]s from interaction events. + Future createMessageChatContext( + Message message, + StringView contentView, + String prefix, + ) async { + ChatCommand command = + commands.getCommand(contentView) ?? (throw CommandNotFoundException(contentView)); + + TextChannel channel = await message.channel.get() as TextChannel; + User user = message.author as User; + + Guild? guild; + Member? member; + if (channel is GuildChannel) { + guild = await (channel as GuildChannel).guild.get(); + member = await guild.members[user.id].get(); + } + + return MessageChatContext( + commands: commands, + guild: guild, + channel: channel, + member: member, + user: user, + command: command, + client: message.manager.client as NyxxGateway, + prefix: prefix, + message: message, + rawArguments: contentView.remaining, + ); + } + + /// Create an [InteractionChatContext] from an [ApplicationCommandInteraction]. + /// + /// [interaction] is the interaction that triggered the command and [command] is the command + /// executed by the event. + /// + /// You might also be interested in: + /// - [createMessageChatContext], for creating [ChatContext]s from message events. + Future createInteractionChatContext( + ApplicationCommandInteraction interaction, + List options, + ChatCommand command, + ) async { + Member? member = interaction.member; + User user = member?.user ?? interaction.user!; + + Map rawArguments = {}; + + for (final option in options) { + rawArguments[option.name] = option.value; + } + + return InteractionChatContext( + commands: commands, + guild: await interaction.guild?.get(), + channel: await interaction.channel!.get() as TextChannel, + member: member, + user: user, + command: command, + client: interaction.manager.client as NyxxGateway, + interaction: interaction, + rawArguments: rawArguments, + ); + } + + /// Create a [UserContext] from an [ApplicationCommandInteraction]. + /// + /// [interaction] is the interaction event that triggered the command and [command] is the + /// command executed by the event. + Future createUserContext( + ApplicationCommandInteraction interaction, + UserCommand command, + ) async { + Member? member = interaction.member; + User user = member?.user ?? interaction.user!; + + final client = interaction.manager.client as NyxxGateway; + + User targetUser = await client.users[interaction.data.targetId!].get(); + Guild? guild = await interaction.guild?.get(); + + return UserContext( + commands: commands, + client: client, + interaction: interaction, + command: command, + channel: await interaction.channel!.get() as TextChannel, + member: member, + user: user, + guild: guild, + targetUser: targetUser, + targetMember: await guild?.members[targetUser.id].get(), + ); + } + + /// Create a [MessageContext] from an [ApplicationCommandInteraction]. + /// + /// [interaction] is the interaction event that triggered the command and [command] is the + /// command executed by the event. + Future createMessageContext( + ApplicationCommandInteraction interaction, + MessageCommand command, + ) async { + Member? member = interaction.member; + User user = member?.user ?? interaction.user!; + + Guild? guild = await interaction.guild?.get(); + + TextChannel channel = await interaction.channel!.get() as TextChannel; + + return MessageContext( + commands: commands, + client: interaction.manager.client as NyxxGateway, + interaction: interaction, + command: command, + channel: channel, + member: member, + user: user, + guild: guild, + targetMessage: await channel.messages[interaction.data.targetId!].get(), + ); + } + + /// Create an [AutocompleteContext] from an [ApplicationCommandAutocompleteInteraction]. + /// + /// [interaction] is the interaction event that triggered the autocomplete action and + /// [command] is the command to which the autocompleted parameter belongs. + Future createAutocompleteContext( + ApplicationCommandAutocompleteInteraction interaction, + ChatCommand command, + ) async { + Member? member = interaction.member; + User user = member?.user ?? interaction.user!; + + return AutocompleteContext( + commands: commands, + guild: await interaction.guild?.get(), + channel: await interaction.channel!.get() as TextChannel, + member: member, + user: user, + command: command, + client: interaction.manager.client as NyxxGateway, + interaction: interaction, + option: interaction.data.options!.singleWhere((element) => element.isFocused == true), + currentValue: interaction.data.options! + .singleWhere((element) => element.isFocused == true) + .value + .toString(), + ); + } + + /// Create a [ButtonComponentContext] from a [MessageComponentInteraction]. + /// + /// [interaction] is the interaction event that triggered this context's creation. + Future createButtonComponentContext( + MessageComponentInteraction interaction, + ) async { + Member? member = interaction.member; + User user = member?.user ?? interaction.user!; + + return ButtonComponentContext( + user: user, + member: member, + guild: await interaction.guild?.get(), + channel: await interaction.channel!.get() as TextChannel, + commands: commands, + client: interaction.manager.client as NyxxGateway, + interaction: interaction, + ); + } + + /// Create a [SelectMenuContext] from a [MessageComponentInteraction]. + /// + /// [interaction] is the interaction event that triggered this context's creation and + /// [selected] is the value(s) that were selected by the user. + Future> createSelectMenuContext( + MessageComponentInteraction interaction, + T selected, + ) async { + Member? member = interaction.member; + User user = member?.user ?? interaction.user!; + + return SelectMenuContext( + user: user, + member: member, + guild: await interaction.guild?.get(), + channel: await interaction.channel!.get() as TextChannel, + commands: commands, + client: interaction.manager.client as NyxxGateway, + interaction: interaction, + selected: selected, + ); + } + + /// Create a [ModalContext] from a [ModalSubmitInteraction]. + /// + /// [interaction] is the interaction event that triggered this context's creation. + Future createModalContext(ModalSubmitInteraction interaction) async { + Member? member = interaction.member; + User user = member?.user ?? interaction.user!; + + return ModalContext( + user: user, + member: member, + guild: await interaction.guild?.get(), + channel: await interaction.channel!.get() as TextChannel, + commands: commands, + client: interaction.manager.client as NyxxGateway, + interaction: interaction, + ); + } +} diff --git a/lib/src/context/interaction_context.dart b/lib/src/context/interaction_context.dart deleted file mode 100644 index 1ff4a4a..0000000 --- a/lib/src/context/interaction_context.dart +++ /dev/null @@ -1,100 +0,0 @@ -// 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 '../context/context.dart'; - -/// 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 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 - /// invoked the command. In interactions, this is done by sending an ephemeral response, in text - /// commands this is handled by sending a Private Message to the user. - /// - /// If [hidden] is set to `true`, the response will be ephemeral (hidden). However, unlike - /// [hidden], not setting [hidden] will result in the value from - /// [CommandOptions.hideOriginalResponse] being used instead. [hidden] will override [private]. - /// - /// You might also be interested in: - /// - [acknowledge], for acknowledging interactions without resopnding. - @override - Future respond(MessageBuilder builder, {bool private = false, bool? hidden}); - - /// Acknowledge the underlying interaction without yet sending a response. - /// - /// While the `hidden` and `private` arguments are guaranteed to hide/show the resulting response, - /// slow commands might sometimes show strange behaviour in their responses. Acknowledging the - /// interaction early with the correct value for [hidden] can prevent this behaviour. - /// - /// You might also be interested in: - /// - [respond], for sending a full response. - Future acknowledge({bool? hidden}); - - @override - ISlashCommandInteractionEvent get interactionEvent; -} - -mixin InteractionContextMixin implements IInteractionContext { - bool _hasCorrectlyAcked = false; - late bool _originalAckHidden = commands.options.hideOriginalResponse; - - @override - Future respond(MessageBuilder builder, {bool private = false, bool? hidden}) async { - hidden ??= private; - - if (_hasCorrectlyAcked) { - return interactionEvent.sendFollowup(builder, hidden: hidden); - } else { - _hasCorrectlyAcked = true; - try { - await interactionEvent.acknowledge(hidden: hidden); - } on AlreadyRespondedError { - // interaction was already ACKed by timeout or [acknowledge], hidden state of ACK might not - // be what we expect - if (_originalAckHidden != hidden) { - await interactionEvent - .sendFollowup(MessageBuilder.content(MessageBuilder.clearCharacter)); - if (!_originalAckHidden) { - // If original response was hidden, we can't delete it - await interactionEvent.deleteOriginalResponse(); - } - } - } - return interactionEvent.sendFollowup(builder, hidden: hidden); - } - } - - @override - Future acknowledge({bool? hidden}) async { - await interactionEvent.acknowledge(hidden: hidden ?? commands.options.hideOriginalResponse); - _originalAckHidden = hidden ?? commands.options.hideOriginalResponse; - } -} diff --git a/lib/src/context/message_context.dart b/lib/src/context/message_context.dart index 360b76a..1d5cf78 100644 --- a/lib/src/context/message_context.dart +++ b/lib/src/context/message_context.dart @@ -1,74 +1,35 @@ -// 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/src/models/interaction.dart'; -import 'package:nyxx_interactions/src/events/interaction_event.dart'; -import '../commands.dart'; import '../commands/message_command.dart'; -import 'component_wrappers.dart'; -import 'interaction_context.dart'; - -/// Representsa context in which a [MessageCommand] was executed. -class MessageContext - with InteractionContextMixin, ComponentWrappersMixin - implements IInteractionContext { - /// The messsage that the user selected when running this command. - final IMessage targetMessage; - - @override - final ITextChannel channel; - - @override - final INyxx client; +import '../util/mixins.dart'; +import 'base.dart'; +/// A context in which a [MessageCommand] was executed. +/// +/// You might also be interested in: +/// - [InteractionCommandContext], the base class for all commands executed from an interaction. +class MessageContext extends ContextBase + with InteractionRespondMixin, InteractiveMixin + implements InteractionCommandContext { @override final MessageCommand command; @override - final CommandsPlugin commands; - - @override - final IGuild? guild; + final ApplicationCommandInteraction interaction; - @override - final ISlashCommandInteraction interaction; - - @override - final ISlashCommandInteractionEvent interactionEvent; - - @override - final IMember? member; - - @override - final IUser user; + /// The message that the user selected when running this command. + final Message targetMessage; /// Create a new [MessageContext]. MessageContext({ required this.targetMessage, - required this.channel, - required this.client, required this.command, - required this.commands, - required this.guild, required this.interaction, - required this.interactionEvent, - required this.member, - required this.user, + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, }); - - @override - String toString() => 'MessageContext[interaction=${interaction.token}, message=$targetMessage}]'; } diff --git a/lib/src/context/modal_context.dart b/lib/src/context/modal_context.dart new file mode 100644 index 0000000..3093ec8 --- /dev/null +++ b/lib/src/context/modal_context.dart @@ -0,0 +1,32 @@ +import 'package:nyxx/nyxx.dart'; + +import '../context/base.dart'; +import '../util/mixins.dart'; + +/// A context in which a user submitted a modal. +class ModalContext extends ContextBase + with InteractionRespondMixin, InteractiveMixin + implements InteractionInteractiveContext { + @override + final ModalSubmitInteraction interaction; + + /// Create a new [ModalContext]. + ModalContext({ + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, + required this.interaction, + }); + + /// Get the value the user inputted in a component based on its [id]. + /// + /// Throws a [StateError] if no component with the given [id] exist in the modal. + String? operator [](String id) => interaction.data.components + .expand((component) => component is ActionRowComponent ? component.components : [component]) + .whereType() + .singleWhere((element) => element.customId == id) + .value; +} diff --git a/lib/src/context/user_context.dart b/lib/src/context/user_context.dart index a577d3c..88993f9 100644 --- a/lib/src/context/user_context.dart +++ b/lib/src/context/user_context.dart @@ -1,77 +1,40 @@ -// 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/user_command.dart'; -import '../context/component_wrappers.dart'; -import '../context/interaction_context.dart'; - -/// Represents a context in which a [UserCommand] was executed. -class UserContext - with InteractionContextMixin, ComponentWrappersMixin - implements IInteractionContext { - /// The member that was selected by the user when running the command if the command was invoked - /// in a guild, `null` otherwise. - final IMember? targetMember; - - /// The user that was selected by the user when running the command. - final IUser targetUser; - - @override - final ITextChannel channel; - - @override - final INyxx client; +import '../util/mixins.dart'; +import 'base.dart'; +/// A context in which a [UserCommand] was executed. +/// +/// You might also be interested in: +/// - [InteractionCommandContext], the base class for all commands executed from an interaction. +class UserContext extends ContextBase + with InteractionRespondMixin, InteractiveMixin + implements InteractionCommandContext { @override final UserCommand command; @override - final CommandsPlugin commands; - - @override - final IGuild? guild; + final ApplicationCommandInteraction interaction; - @override - final IMember? member; - - @override - final IUser user; - - @override - final ISlashCommandInteraction interaction; + /// The member that was selected by the user when running the command if the command was invoked + /// in a guild, `null` otherwise. + final Member? targetMember; - @override - final ISlashCommandInteractionEvent interactionEvent; + /// The user that was selected by the user when running the command. + final User targetUser; + /// Create a new [UserContext]. UserContext({ required this.targetMember, required this.targetUser, - required this.channel, - required this.client, required this.command, - required this.commands, - required this.guild, - required this.member, - required this.user, required this.interaction, - required this.interactionEvent, + required super.user, + required super.member, + required super.guild, + required super.channel, + required super.commands, + required super.client, }); - - @override - String toString() => 'UserContext[interaction=${interaction.token}, target=$targetUser]'; } diff --git a/lib/src/converters/built_in.dart b/lib/src/converters/built_in.dart new file mode 100644 index 0000000..c580bc7 --- /dev/null +++ b/lib/src/converters/built_in.dart @@ -0,0 +1,18 @@ +export 'built_in/attachment.dart' show attachmentConverter; +export 'built_in/bool.dart' show boolConverter; +export 'built_in/guild_channel.dart' + show + GuildChannelConverter, + categoryGuildChannelConverter, + guildChannelConverter, + stageVoiceChannelConverter, + textGuildChannelConverter, + voiceGuildChannelConverter; +export 'built_in/member.dart' show memberConverter; +export 'built_in/mentionable.dart' show mentionableConverter; +export 'built_in/number.dart' + show DoubleConverter, IntConverter, NumConverter, doubleConverter, intConverter; +export 'built_in/role.dart' show roleConverter; +export 'built_in/snowflake.dart' show snowflakeConverter; +export 'built_in/string.dart' show stringConverter; +export 'built_in/user.dart' show userConverter; diff --git a/lib/src/converters/built_in/attachment.dart b/lib/src/converters/built_in/attachment.dart new file mode 100644 index 0000000..26efee3 --- /dev/null +++ b/lib/src/converters/built_in/attachment.dart @@ -0,0 +1,86 @@ +import 'package:nyxx/nyxx.dart'; + +import '../../context/base.dart'; +import '../../context/chat_context.dart'; +import '../../util/view.dart'; +import '../combine.dart'; +import '../converter.dart'; +import '../fallback.dart'; +import 'snowflake.dart'; + +Attachment? snowflakeToAttachment(Snowflake id, ContextData context) { + Iterable? attachments = switch (context) { + InteractionChatContext(:final interaction) => interaction.data.resolved?.attachments?.values, + MessageChatContext(:final message) => message.attachments, + _ => null, + }; + + try { + return attachments?.singleWhere((attachment) => attachment.id == id); + } on StateError { + return null; + } +} + +Attachment? convertAttachment(StringView view, ContextData context) { + String fileName = view.getQuotedWord(); + + Iterable? attachments = switch (context) { + InteractionChatContext(:final interaction) => interaction.data.resolved?.attachments?.values, + MessageChatContext(:final message) => message.attachments, + _ => null, + }; + + if (attachments == null) { + return null; + } + + Iterable exactMatch = attachments.where( + (attachment) => attachment.fileName == fileName, + ); + + Iterable caseInsensitive = attachments.where( + (attachment) => attachment.fileName.toLowerCase() == fileName.toLowerCase(), + ); + + Iterable partialMatch = attachments.where( + (attachment) => attachment.fileName.toLowerCase().startsWith(fileName.toLowerCase()), + ); + + for (final list in [exactMatch, caseInsensitive, partialMatch]) { + if (list.length == 1) { + return list.first; + } + } + + return null; +} + +SelectMenuOptionBuilder attachmentToSelectMenuOption(Attachment attachment) => + SelectMenuOptionBuilder( + label: attachment.fileName, + value: attachment.id.toString(), + ); + +ButtonBuilder attachmentToButton(Attachment attachment) => ButtonBuilder( + style: ButtonStyle.primary, + label: attachment.fileName, + customId: '', + ); + +/// A converter that converts input to an [Attachment]. +/// +/// This will first attempt to parse the input to a snowflake that will then be resolved as the ID +/// of one of the attachments in the message or interaction. If this fails, then the attachment will +/// be looked up by name. +/// +/// This converter has a Discord Slash Command argument type of [CommandOptionType.attachment]. +const Converter attachmentConverter = FallbackConverter( + [ + CombineConverter(snowflakeConverter, snowflakeToAttachment), + Converter(convertAttachment), + ], + type: CommandOptionType.attachment, + toSelectMenuOption: attachmentToSelectMenuOption, + toButton: attachmentToButton, +); diff --git a/lib/src/converters/built_in/bool.dart b/lib/src/converters/built_in/bool.dart new file mode 100644 index 0000000..a2ecf5d --- /dev/null +++ b/lib/src/converters/built_in/bool.dart @@ -0,0 +1,48 @@ +import 'package:nyxx/nyxx.dart'; + +import '../../context/base.dart'; +import '../../util/view.dart'; +import '../converter.dart'; + +bool? convertBool(StringView view, ContextData context) { + String word = view.getQuotedWord(); + + const Iterable truthy = ['y', 'yes', '+', '1', 'true']; + const Iterable falsy = ['n', 'no', '-', '0', 'false']; + + const Iterable valid = [...truthy, ...falsy]; + + if (valid.contains(word.toLowerCase())) { + return truthy.contains(word.toLowerCase()); + } + + return null; +} + +SelectMenuOptionBuilder boolToSelectMenuOption(bool value) => SelectMenuOptionBuilder( + label: value ? 'True' : 'False', + value: value.toString(), + ); + +ButtonBuilder boolToButton(bool value) => ButtonBuilder( + style: ButtonStyle.primary, + label: value ? 'True' : 'False', + customId: '', + ); + +/// A [Converter] that converts input to a [bool]. +/// +/// This converter will parse the input to `true` if the next word or quoted section of the input is +/// one of `y`, `yes`, `+`, `1` or `true`. This comparison is case-insensitive. +/// This converter will parse the input to `false` if the next word or quoted section of the input +/// is one of `n`, `no`, `-`, `0` or `false`. This comparison is case-insensitive. +/// +/// If the input is not one of the aforementioned words, this converter will fail. +/// +/// This converter has a Discord Slash Command Argument Type of [CommandOptionType.boolean]. +const Converter boolConverter = Converter( + convertBool, + type: CommandOptionType.boolean, + toSelectMenuOption: boolToSelectMenuOption, + toButton: boolToButton, +); diff --git a/lib/src/converters/built_in/guild_channel.dart b/lib/src/converters/built_in/guild_channel.dart new file mode 100644 index 0000000..8968653 --- /dev/null +++ b/lib/src/converters/built_in/guild_channel.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import '../../context/autocomplete_context.dart'; +import '../../context/base.dart'; +import '../../converters/fallback.dart'; +import '../../util/view.dart'; +import '../combine.dart'; +import '../converter.dart'; +import 'snowflake.dart'; + +Future snowflakeToGuildChannel(Snowflake snowflake, ContextData context) async { + if (context.guild != null) { + try { + final channel = await context.client.channels[snowflake].get(); + + if (channel is GuildChannel && channel.guildId == context.guild!.id) { + return channel; + } + } on HttpResponseError { + return null; + } + } + + return null; +} + +Future convertGuildChannel(StringView view, ContextData context) async { + if (context.guild != null) { + String word = view.getQuotedWord(); + + if (word.startsWith('#')) { + word = word.substring(1); + } + + List caseInsensitive = []; + List partial = []; + + for (final channel in await context.guild!.fetchChannels()) { + if (channel.name.toLowerCase() == word.toLowerCase()) { + caseInsensitive.add(channel); + } + if (channel.name.toLowerCase().startsWith(word.toLowerCase())) { + partial.add(channel); + } + } + + for (final list in [caseInsensitive, partial]) { + if (list.length == 1) { + return list.first; + } + } + } + + return null; +} + +/// A converter that converts input to one or more types of [GuildChannel]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`. +/// +/// +/// 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 [GuildChannel]s; +/// - [textGuildChannelConverter], a converter for [GuildTextChannel]s; +/// - [voiceGuildChannelConverter], a converter for [GuildVoiceChannel]s; +/// - [categoryGuildChannelConverter], a converter for [GuildCategory]s; +/// - [stageVoiceChannelConverter], a converter for [GuildStageChannel]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, ContextData) get convert => (view, context) async { + GuildChannel? 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 + FutureOr>?> Function(AutocompleteContext)? + get autocompleteCallback => null; + + @override + RuntimeType get output => RuntimeType(); + + @override + CommandOptionType get type => CommandOptionType.channel; + + @override + SelectMenuOptionBuilder Function(T) get toSelectMenuOption => + (channel) => SelectMenuOptionBuilder( + label: channel.name, + value: channel.id.toString(), + description: channel is GuildTextChannel ? channel.topic : null, + ); + + @override + ButtonBuilder Function(T) get toButton => (channel) => ButtonBuilder( + style: ButtonStyle.primary, + label: '#${channel.name}', + customId: '', + ); +} + +/// A converter that converts input to a [GuildChannel]. +/// +/// This will first attempt to parse the input as a [Snowflake] that will then be converted to an +/// [GuildChannel]. 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] and is +/// set to accept all channel types. +const GuildChannelConverter guildChannelConverter = GuildChannelConverter(null); + +/// A converter that converts input to an [GuildTextChannel]. +/// +/// This will first attempt to parse the input as a [Snowflake] that will then be converted to an +/// [GuildTextChannel]. 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] and is +/// set to accept channels of type [ChannelType.guildText]. +const GuildChannelConverter textGuildChannelConverter = GuildChannelConverter([ + ChannelType.guildText, +]); + +/// A converter that converts input to an [GuildVoiceChannel]. +/// +/// This will first attempt to parse the input as a [Snowflake] that will then be converted to an +/// [GuildVoiceChannel]. 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] and is +/// set to accept channels of type [ChannelType.guildVoice]. +const GuildChannelConverter voiceGuildChannelConverter = GuildChannelConverter([ + ChannelType.guildVoice, +]); + +/// A converter that converts input to an [GuildCategory]. +/// +/// This will first attempt to parse the input as a [Snowflake] that will then be converted to an +/// [GuildCategory]. 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] and it +/// set to accept channels of type [ChannelType.guildCategory]. +const GuildChannelConverter categoryGuildChannelConverter = GuildChannelConverter([ + ChannelType.guildCategory, +]); + +/// A converter that converts input to an [GuildStageChannel]. +/// +/// This will first attempt to parse the input as a [Snowflake] that will then be converted to an +/// [GuildStageChannel]. 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] and is +/// set to accept channels of type [ChannelType.guildStageVoice]. +const GuildChannelConverter stageVoiceChannelConverter = GuildChannelConverter([ + ChannelType.guildStageVoice, +]); diff --git a/lib/src/converters/built_in/member.dart b/lib/src/converters/built_in/member.dart new file mode 100644 index 0000000..474b2ec --- /dev/null +++ b/lib/src/converters/built_in/member.dart @@ -0,0 +1,117 @@ +import 'package:nyxx/nyxx.dart'; + +import '../../context/base.dart'; +import '../../util/view.dart'; +import '../combine.dart'; +import '../converter.dart'; +import '../fallback.dart'; +import 'snowflake.dart'; + +Future snowflakeToMember(Snowflake snowflake, ContextData context) async { + try { + return await context.guild?.members.get(snowflake); + } on HttpResponseError { + return null; + } +} + +Future convertMember(StringView view, ContextData context) async { + String word = view.getQuotedWord(); + + if (context.guild != null) { + Stream named = context.client.gateway.listGuildMembers( + context.guild!.id, + query: word, + limit: 100, + ); + + List usernameExact = []; + List nickExact = []; + + List usernameCaseInsensitive = []; + List nickCaseInsensitive = []; + + List usernameStart = []; + List nickStart = []; + + await for (final member in named) { + User user = await context.client.users.get(member.id); + + if (user.username == word) { + usernameExact.add(member); + } + if (user.username.toLowerCase() == word.toLowerCase()) { + usernameCaseInsensitive.add(member); + } + if (user.username.toLowerCase().startsWith(word.toLowerCase())) { + usernameStart.add(member); + } + + if (member.nick != null) { + if (member.nick! == word) { + nickExact.add(member); + } + if (member.nick!.toLowerCase() == word.toLowerCase()) { + nickCaseInsensitive.add(member); + } + if (member.nick!.toLowerCase().startsWith(word.toLowerCase())) { + nickStart.add(member); + } + } + } + + for (final list in [ + usernameExact, + nickExact, + usernameCaseInsensitive, + nickCaseInsensitive, + usernameStart, + nickStart + ]) { + if (list.length == 1) { + return list.first; + } + } + } + return null; +} + +Future memberToSelectMenuOption(Member member) async { + User user = await member.manager.client.users.get(member.id); + String name = member.nick ?? user.globalName ?? user.username; + + return SelectMenuOptionBuilder( + label: name, + value: member.id.toString(), + description: '@${user.username}', + ); +} + +Future memberToButton(Member member) async { + User user = await member.manager.client.users.get(member.id); + String name = member.nick ?? user.globalName ?? user.username; + + return ButtonBuilder( + style: ButtonStyle.primary, + label: '$name (@${user.globalName})', + customId: '', + ); +} + +/// A converter that converts input to a [Member]. +/// +/// This will first attempt to parse the input to a snowflake which will then be converted to an +/// [Member]. If this fails, the member will be looked up by name. +/// +/// This converter has a Discord Slash Command Argument Type of [CommandOptionType.user]. +const Converter memberConverter = FallbackConverter( + [ + // Get member from mention or snowflake. + CombineConverter(snowflakeConverter, snowflakeToMember), + // Get member by name or nickname + Converter(convertMember), + ], + type: CommandOptionType.user, + toSelectMenuOption: memberToSelectMenuOption, + toButton: memberToButton, +); diff --git a/lib/src/converters/built_in/mentionable.dart b/lib/src/converters/built_in/mentionable.dart new file mode 100644 index 0000000..b007a03 --- /dev/null +++ b/lib/src/converters/built_in/mentionable.dart @@ -0,0 +1,19 @@ +import 'package:nyxx/nyxx.dart'; + +import '../converter.dart'; +import '../fallback.dart'; +import 'user.dart'; +import 'role.dart'; + +/// A converter that converts input to a [CommandOptionMentionable]. +/// +/// This will first attempt to convert the input as a user, then as a role. +/// +/// This converter has a Discord Slash Command argument type of [CommandOptionType.mentionable]. +const Converter mentionableConverter = FallbackConverter( + [ + userConverter, + roleConverter, + ], + type: CommandOptionType.mentionable, +); diff --git a/lib/src/converters/built_in/number.dart b/lib/src/converters/built_in/number.dart new file mode 100644 index 0000000..23dda5b --- /dev/null +++ b/lib/src/converters/built_in/number.dart @@ -0,0 +1,108 @@ +import 'package:nyxx/nyxx.dart'; + +import '../../context/base.dart'; +import '../../util/view.dart'; +import '../converter.dart'; + +/// 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, ContextData) super.convert, { + required super.type, + this.min, + this.max, + }); + + @override + void Function(CommandOptionBuilder)? get processOptionCallback => (builder) { + builder.minValue = min; + builder.maxValue = max; + }; + + @override + SelectMenuOptionBuilder Function(T) get toSelectMenuOption => (n) => SelectMenuOptionBuilder( + label: n.toString(), + value: n.toString(), + ); + + @override + ButtonBuilder Function(T) get toButton => (n) => ButtonBuilder( + style: ButtonStyle.primary, + label: n.toString(), + customId: '', + ); +} + +int? convertInt(StringView view, ContextData context) => int.tryParse(view.getQuotedWord()); + +/// 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({ + super.min, + super.max, + }) : super( + convertInt, + type: CommandOptionType.integer, + ); +} + +/// 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 = IntConverter(); + +double? convertDouble(StringView view, ContextData 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({ + super.min, + super.max, + }) : super( + convertDouble, + type: CommandOptionType.number, + ); +} + +/// 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 = DoubleConverter(); diff --git a/lib/src/converters/built_in/role.dart b/lib/src/converters/built_in/role.dart new file mode 100644 index 0000000..e1da5c5 --- /dev/null +++ b/lib/src/converters/built_in/role.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import '../../context/base.dart'; +import '../../util/view.dart'; +import '../combine.dart'; +import '../converter.dart'; +import '../fallback.dart'; +import 'snowflake.dart'; + +Future snowflakeToRole(Snowflake snowflake, ContextData context) async { + try { + return await context.guild?.roles.get(snowflake); + } on RoleNotFoundException { + return null; + } +} + +Future convertRole(StringView view, ContextData context) async { + String word = view.getQuotedWord(); + + if (context.guild != null) { + List roles = await context.guild!.roles.list(); + + List exact = []; + List caseInsensitive = []; + List partial = []; + + for (final role in roles) { + if (role.name == word) { + exact.add(role); + } + if (role.name.toLowerCase() == word.toLowerCase()) { + caseInsensitive.add(role); + } + if (role.name.toLowerCase().startsWith(word.toLowerCase())) { + partial.add(role); + } + } + + for (final list in [exact, caseInsensitive, partial]) { + if (list.length == 1) { + return list.first; + } + } + } + return null; +} + +SelectMenuOptionBuilder roleToSelectMenuOption(Role role) { + SelectMenuOptionBuilder builder = SelectMenuOptionBuilder( + label: role.name, + value: role.id.toString(), + ); + + if (role.unicodeEmoji != null) { + builder.emoji = TextEmoji( + id: Snowflake.zero, + manager: role.manager.client.guilds[Snowflake.zero].emojis, + name: role.unicodeEmoji!, + ); + } + + return builder; +} + +ButtonBuilder roleToButton(Role role) { + final builder = ButtonBuilder( + style: ButtonStyle.primary, + label: role.name, + customId: '', + ); + + if (role.unicodeEmoji != null) { + builder.emoji = TextEmoji( + id: Snowflake.zero, + manager: role.manager.client.guilds[Snowflake.zero].emojis, + name: role.unicodeEmoji!, + ); + } + + return builder; +} + +/// A converter that converts input to a [Role]. +/// +/// This will first attempt to parse the input as a snowflake that will then be converted to a +/// [Role]. If this fails, then the role will be looked up by name in the current guild. +/// +/// This converter has a Discord Slash Command argument type of [CommandOptionType.role]. +const Converter roleConverter = FallbackConverter( + [ + CombineConverter(snowflakeConverter, snowflakeToRole), + Converter(convertRole), + ], + type: CommandOptionType.role, + toSelectMenuOption: roleToSelectMenuOption, + toButton: roleToButton, +); diff --git a/lib/src/converters/built_in/snowflake.dart b/lib/src/converters/built_in/snowflake.dart new file mode 100644 index 0000000..7d4ad75 --- /dev/null +++ b/lib/src/converters/built_in/snowflake.dart @@ -0,0 +1,39 @@ +import 'package:nyxx/nyxx.dart'; + +import '../../context/base.dart'; +import '../../util/view.dart'; +import '../converter.dart'; + +final RegExp _snowflakePattern = RegExp(r'^(?:<(?:@(?:!|&)?|#)([0-9]{15,20})>|([0-9]{15,20}))$'); + +Snowflake? convertSnowflake(StringView view, ContextData context) { + final match = _snowflakePattern.firstMatch(view.getQuotedWord()); + + if (match == null) { + return null; + } + + // 1st group will catch mentions, second will catch raw IDs + return Snowflake.parse(match.group(1) ?? match.group(2)!); +} + +SelectMenuOptionBuilder snowflakeToSelectMenuOption(Snowflake snowflake) => SelectMenuOptionBuilder( + label: snowflake.toString(), + value: snowflake.toString(), + ); + +ButtonBuilder snowflakeToButton(Snowflake snowflake) => ButtonBuilder( + style: ButtonStyle.primary, + label: snowflake.toString(), + customId: '', + ); + +/// A converter that converts input to a [Snowflake]. +/// +/// This converter will parse user mentions, member mentions, channel mentions or raw integers as +/// snowflakes. +const Converter snowflakeConverter = Converter( + convertSnowflake, + toSelectMenuOption: snowflakeToSelectMenuOption, + toButton: snowflakeToButton, +); diff --git a/lib/src/converters/built_in/string.dart b/lib/src/converters/built_in/string.dart new file mode 100644 index 0000000..f7b24f8 --- /dev/null +++ b/lib/src/converters/built_in/string.dart @@ -0,0 +1,31 @@ +import 'package:nyxx/nyxx.dart'; + +import '../../context/base.dart'; +import '../../util/view.dart'; +import '../converter.dart'; + +String? convertString(StringView view, ContextData context) => view.getQuotedWord(); + +SelectMenuOptionBuilder stringToSelectMenuOption(String value) => SelectMenuOptionBuilder( + label: value, + value: value, + ); + +ButtonBuilder stringToButton(String value) => ButtonBuilder( + style: ButtonStyle.primary, + label: value, + customId: '', + ); + +/// A [Converter] that converts input to a [String]. +/// +/// This converter returns the next space-separated word in the input, or, if the next word in the +/// input is quoted, the next quoted section of the input. +/// +/// This converter has a Discord Slash Command Argument Type of [CommandOptionType.string]. +const Converter stringConverter = Converter( + convertString, + type: CommandOptionType.string, + toSelectMenuOption: stringToSelectMenuOption, + toButton: stringToButton, +); diff --git a/lib/src/converters/built_in/user.dart b/lib/src/converters/built_in/user.dart new file mode 100644 index 0000000..deb6047 --- /dev/null +++ b/lib/src/converters/built_in/user.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import '../../context/base.dart'; +import '../../util/view.dart'; +import '../combine.dart'; +import '../converter.dart'; +import '../fallback.dart'; +import 'member.dart'; +import 'snowflake.dart'; + +Future snowflakeToUser(Snowflake snowflake, ContextData context) async { + try { + return await context.client.users.get(snowflake); + } on HttpResponseError { + return null; + } +} + +FutureOr memberToUser(Member member, ContextData context) => + member.manager.client.users.get(member.id); + +Future convertUser(StringView view, ContextData context) async { + String word = view.getWord(); + TextChannel channel = context.channel; + + if (channel.type == ChannelType.dm || channel.type == ChannelType.groupDm) { + List exact = []; + List caseInsensitive = []; + List start = []; + + for (final user in [ + if (channel is DmChannel) channel.recipient, + if (channel is GroupDmChannel) ...channel.recipients, + await context.client.users.fetchCurrentUser(), + ]) { + if (user.username == word) { + exact.add(user); + } + + if (user.username.toLowerCase() == word.toLowerCase()) { + caseInsensitive.add(user); + } + + if (user.username.toLowerCase().startsWith(word.toLowerCase())) { + start.add(user); + } + + for (final list in [exact, caseInsensitive, start]) { + if (list.length == 1) { + return list.first; + } + } + } + } + + return null; +} + +SelectMenuOptionBuilder userToSelectMenuOption(User user) => SelectMenuOptionBuilder( + label: '@${user.username}', + value: user.id.toString(), + ); + +ButtonBuilder userToButton(User user) => ButtonBuilder( + style: ButtonStyle.primary, + label: '@${user.username}', + customId: '', + ); + +/// A converter that converts input to a [User]. +/// +/// This will first attempt to parse the input to a snowflake which will then be converted to a +/// [User]. If this fails, the input will be parsed as a [Member] which will then be converted to +/// a [User]. If this fails, the user will be looked up by name. +/// +/// This converter has a Discord Slash Command Argument Type of [CommandOptionType.user]. +const Converter userConverter = FallbackConverter( + [ + CombineConverter(snowflakeConverter, snowflakeToUser), + CombineConverter(memberConverter, memberToUser), + Converter(convertUser), + ], + type: CommandOptionType.user, + toSelectMenuOption: userToSelectMenuOption, + toButton: userToButton, +); diff --git a/lib/src/converters/combine.dart b/lib/src/converters/combine.dart new file mode 100644 index 0000000..fe857ad --- /dev/null +++ b/lib/src/converters/combine.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import '../context/autocomplete_context.dart'; +import '../context/base.dart'; +import '../util/view.dart'; +import 'converter.dart'; + +/// A converter that extends the functionality of an existing converter, piping its output through +/// another function. +/// +/// This has the effect of allowing further processing of the output of a converter, for example to +/// transform a [Snowflake] into a [Member]. +/// +/// You might also be interested in: +/// - [FallbackConverter], a converter that tries multiple converters successively. +class CombineConverter implements Converter { + /// The converter used to parse the original input to the intermediate type. + final Converter converter; + + /// The function that transforms the intermediate type into the output type. + /// + /// As with normal converters, this function should not throw but can return `null` to indicate + /// parsing failure. + final FutureOr Function(R, ContextData) process; + + @override + RuntimeType get output => RuntimeType(); + + final void Function(CommandOptionBuilder)? _customProcessOptionCallback; + + @override + void Function(CommandOptionBuilder)? get processOptionCallback => + _customProcessOptionCallback ?? converter.processOptionCallback; + + final FutureOr>?> Function(AutocompleteContext)? + _autocompleteCallback; + + @override + FutureOr>?> Function(AutocompleteContext)? + get autocompleteCallback => _autocompleteCallback ?? converter.autocompleteCallback; + + @override + final FutureOr Function(T)? toSelectMenuOption; + + @override + final FutureOr Function(T)? toButton; + + final Iterable>? _choices; + final CommandOptionType? _type; + + /// Create a new [CombineConverter]. + const CombineConverter( + this.converter, + this.process, { + Iterable>? choices, + CommandOptionType? type, + void Function(CommandOptionBuilder)? processOptionCallback, + FutureOr>?> Function(AutocompleteContext)? + autocompleteCallback, + this.toSelectMenuOption, + this.toButton, + }) : _choices = choices, + _type = type, + _customProcessOptionCallback = processOptionCallback, + _autocompleteCallback = autocompleteCallback; + + @override + Iterable>? get choices => _choices ?? converter.choices; + + @override + CommandOptionType get type => _type ?? converter.type; + + @override + FutureOr Function(StringView view, ContextData context) get convert => (view, context) async { + R? ret = await converter.convert(view, context); + + if (ret != null) { + return await process(ret, context); + } + return null; + }; + + @override + String toString() => 'CombineConverter<$R, $T>[converter=$converter]'; +} diff --git a/lib/src/converters/converter.dart b/lib/src/converters/converter.dart index 7eaaff2..58d6479 100644 --- a/lib/src/converters/converter.dart +++ b/lib/src/converters/converter.dart @@ -1,27 +1,13 @@ -// 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 'dart:async'; 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 '../context/base.dart'; import '../errors.dart'; import '../util/view.dart'; +import 'built_in.dart'; /// Contains metadata and parsing capabilities for a given type. /// @@ -38,14 +24,15 @@ import '../util/view.dart'; /// - [doubleConverter], which converts [double]s; /// - [boolConverter], which converts [bool]s; /// - [snowflakeConverter], which converts [Snowflake]s; -/// - [memberConverter], which converts [IMember]s; -/// - [userConverter], which converts [IUser]s; -/// - [guildChannelConverter], which converts [IGuildChannel]s; -/// - [textGuildChannelConverter], which converts [ITextGuildChannel]s; -/// - [voiceGuildChannelConverter], which converts [IVoiceGuildChannel]s; -/// - [stageVoiceChannelConverter], which converts [IStageVoiceGuildChannel]s; -/// - [roleConverter], which converts [IRole]s; -/// - [mentionableConverter], which converts [Mentionable]s. +/// - [memberConverter], which converts [Member]s; +/// - [userConverter], which converts [User]s; +/// - [guildChannelConverter], which converts [GuildChannel]s; +/// - [textGuildChannelConverter], which converts [GuildTextChannel]s; +/// - [voiceGuildChannelConverter], which converts [GuildVoiceChannel]s; +/// - [stageVoiceChannelConverter], which converts [GuildStageChannel]s; +/// - [roleConverter], which converts [Role]s; +/// - [mentionableConverter], which converts [CommandOptionMentionable]s; +/// - [attachmentConverter], which converts [Attachment]s. /// /// You can override these default implementations with your own by calling /// [CommandsPlugin.addConverter] with your own converter for one of the types mentioned above. @@ -53,7 +40,8 @@ import '../util/view.dart'; /// You might also be interested in: /// - [CommandsPlugin.addConverter], for adding your own converters to your bot; /// - [FallbackConverter], for successively trying converters until one succeeds; -/// - [CombineConverter], for piping the output of one converter into another. +/// - [CombineConverter], for piping the output of one converter into another; +/// - [SimpleConverter], for creating simple converters. class Converter { /// The function called to perform the conversion. /// @@ -64,13 +52,13 @@ class Converter { /// This function should not throw if parsing fails, it should instead return `null` to indicate /// failure. A [BadInputException] will then be added to [CommandsPlugin.onCommandError] where it /// can be handled appropriately. - final FutureOr Function(StringView view, IChatContext context) convert; + final FutureOr Function(StringView view, ContextData context) convert; /// The choices for this type. /// - /// Choices will be the only options choosable in Slash Commands, however text commands might + /// Choices will be the only options selectable in Slash Commands, however text commands might /// still pass any content to this converter. - final Iterable? choices; + final Iterable>? choices; /// The [Discord Slash Command Argument Type](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type) /// of the type that this converter parses. @@ -82,7 +70,7 @@ class Converter { /// The type that this converter parses. /// /// Used by [CommandsPlugin.getConverter] to construct assembled converters. - final Type output; + RuntimeType get output => RuntimeType(); /// A callback called with the [CommandOptionBuilder] created for an option using this converter. /// @@ -97,7 +85,28 @@ class Converter { /// message in their client. /// /// This function should return at most 25 results and should not throw. - final FutureOr?> Function(AutocompleteContext)? autocompleteCallback; + final FutureOr>?> Function(AutocompleteContext)? + autocompleteCallback; + + /// A function called to provide [SelectMenuOptionBuilder]s that can be used to represent an + /// element converted by this converter. + /// + /// The builder returned by this function should have a value that this converter will be able to + /// convert. + /// + /// You might also be interested in: + /// - [InteractiveContext.getSelection] and [InteractiveContext.getMultiSelection], which make + /// use of this function; + /// - [toButton], similar to this function but for [ButtonBuilder]s. + final FutureOr Function(T)? toSelectMenuOption; + + /// A function called to provide [ButtonBuilder]s that can be used to represent an element + /// converted by this converter. + /// + /// You might also be interested in: + /// - [InteractiveContext.getButtonSelection], which makes use of this function; + /// - [toSelectMenuOption], similar to this function but for [SelectMenuOptionBuilder]s. + final FutureOr Function(T)? toButton; /// Create a new converter. /// @@ -109,815 +118,14 @@ class Converter { this.processOptionCallback, this.autocompleteCallback, this.type = CommandOptionType.string, - }) : output = T; + this.toSelectMenuOption, + this.toButton, + }); @override String toString() => 'Converter<$T>'; } -/// A converter that extends the functionality of an existing converter, piping its output through -/// another function. -/// -/// This has the effect of allowing further processing of the output of a converter, for example to -/// transform a [Snowflake] into a [IMember]. -/// -/// You might also be interested in: -/// - [FallbackConverter], a converter that tries multiple converters succesively. -class CombineConverter implements Converter { - /// The converter used to parse the original input to the intermidate type. - final Converter converter; - - /// The function that transforms the intermediate type into the output type. - /// - /// As with normal converters, this function should not throw but can return `null` to indicate - /// parsing failure. - final FutureOr Function(R, IChatContext) process; - - @override - final Type output; - - final void Function(CommandOptionBuilder)? _customProcessOptionCallback; - - @override - 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; - - /// Create a new [CombineConverter]. - const CombineConverter( - this.converter, - this.process, { - Iterable? choices, - CommandOptionType? type, - void Function(CommandOptionBuilder)? processOptionCallback, - FutureOr?> Function(AutocompleteContext)? autocompleteCallback, - }) : _choices = choices, - _type = type, - output = T, - _customProcessOptionCallback = processOptionCallback, - _autocompleteCallback = autocompleteCallback; - - @override - Iterable? get choices => _choices ?? converter.choices; - - @override - CommandOptionType get type => _type ?? converter.type; - - @override - FutureOr Function(StringView view, IChatContext context) get convert => - (view, context) async { - R? ret = await converter.convert(view, context); - - if (ret != null) { - return await process(ret, context); - } - return null; - }; - - @override - String toString() => 'CombineConverter<$R, $T>[converter=$converter]'; -} - -/// A converter that successively tries a list of converters until one succeeds. -/// -/// Given three converters *a*, *b* and *c*, a [FallbackConverter] will first try to convert the -/// input using *a*, then, if *a* failed, using *b*, then, if *b* failed, using *c*. If all of *a*, -/// *b* and *c* fail, then the [FallbackConverter] will also fail. If at least one of *a*, *b* or -/// *c* succeed, the [FallbackConverter] will return the result of that conversion. -/// -/// You might also be interested in: -/// - [CombineConverter], for further processing the output of another converter. -class FallbackConverter implements Converter { - /// The converters this [FallbackConverter] will attempt to use. - final Iterable> converters; - - @override - final void Function(CommandOptionBuilder)? processOptionCallback; - - @override - final FutureOr?> Function(AutocompleteContext)? autocompleteCallback; - - final Iterable? _choices; - final CommandOptionType? _type; - - @override - final Type output; - - /// Create a new [FallbackConverter]. - const FallbackConverter( - this.converters, { - Iterable? choices, - CommandOptionType? type, - this.processOptionCallback, - this.autocompleteCallback, - }) : _choices = choices, - _type = type, - output = T; - - @override - Iterable? get choices { - if (_choices != null) { - return _choices; - } - - List allChoices = []; - - for (final converter in converters) { - Iterable? converterChoices = converter.choices; - - if (converterChoices == null) { - return null; - } - - for (final choice in converterChoices) { - ArgChoiceBuilder existing = - allChoices.singleWhere((element) => element.name == choice.name, orElse: () => choice); - - if (existing.value != choice.value) { - return null; - } else if (identical(choice, existing)) { - allChoices.add(choice); - } - } - } - - if (allChoices.isEmpty || allChoices.length > 25) { - return null; - } - - return allChoices; - } - - @override - CommandOptionType get type { - if (_type != null) { - return _type!; - } - - Iterable converterTypes = converters.map((converter) => converter.type); - - if (converterTypes.every((element) => element == converterTypes.first)) { - return converterTypes.first; - } - - return CommandOptionType.string; - } - - @override - FutureOr Function(StringView view, IChatContext context) get convert => - (view, context) async { - StringView? used; - T? ret = await converters.fold(Future.value(null), (previousValue, element) async { - if (await previousValue != null) { - return await previousValue; - } - - used = view.copy(); - return await element.convert(used!, context); - }); - - if (used != null) { - view.history - ..clear() - ..addAll(used!.history); - - view.index = used!.index; - } - - return ret; - }; - - @override - String toString() => 'FallbackConverter<$T>[converters=${List.of(converters)}]'; -} - -String? convertString(StringView view, IChatContext context) => view.getQuotedWord(); - -/// A [Converter] that converts input to a [String]. -/// -/// This converter returns the next space-seperated word in the input, or, if the next word in the -/// input is quoted, the next quoted section of the input. -/// -/// This converter has a Discord Slash Command Argument Type of [CommandOptionType.string]. -const Converter stringConverter = Converter( - convertString, - type: CommandOptionType.string, -); - -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 = 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.number, - 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 = DoubleConverter(); - -bool? convertBool(StringView view, IChatContext context) { - String word = view.getQuotedWord(); - - const Iterable truthy = ['y', 'yes', '+', '1', 'true']; - const Iterable falsy = ['n', 'no', '-', '0', 'false']; - - const Iterable valid = [...truthy, ...falsy]; - - if (valid.contains(word.toLowerCase())) { - return truthy.contains(word.toLowerCase()); - } - - return null; -} - -/// A [Converter] that converts input to a [bool]. -/// -/// This converter will parse the input to `true` if the next word or quoted section of the input is -/// one of `y`, `yes`, `+`, `1` or `true`. This comparison is case-insensitive. -/// This converter will parse the input to `false` if the next work or quotetd section of the input -/// is one of `n`, `no`, `-`, `0` or `false`. This comparison is case-insentive. -/// -/// If the input is not one of the aforementioned words, this converter will fail. -/// -/// This converter has a Discord Slash Command Argument Type of [CommandOptionType.boolean]. -const Converter boolConverter = Converter( - convertBool, - type: CommandOptionType.boolean, -); - -final RegExp _snowflakePattern = RegExp(r'^(?:<(?:@(?:!|&)?|#)([0-9]{15,20})>|([0-9]{15,20}))$'); - -Snowflake? convertSnowflake(StringView view, IChatContext context) { - String word = view.getQuotedWord(); - if (!_snowflakePattern.hasMatch(word)) { - return null; - } - - final RegExpMatch match = _snowflakePattern.firstMatch(word)!; - - // 1st group will catch mentions, second will catch raw IDs - return Snowflake(match.group(1) ?? match.group(2)); -} - -/// A converter that converts input to a [Snowflake]. -/// -/// This converter will parse user mentions, member mentions, channel mentions or raw integers as -/// snowflakes. -const Converter snowflakeConverter = Converter( - convertSnowflake, -); - -Future snowflakeToMember(Snowflake snowflake, IChatContext context) async { - if (context.guild != null) { - IMember? cached = context.guild!.members[snowflake]; - if (cached != null) { - return cached; - } - - try { - return await context.guild!.fetchMember(snowflake); - } on IHttpResponseError { - return null; - } - } - return null; -} - -Future convertMember(StringView view, IChatContext context) async { - String word = view.getQuotedWord(); - - if (context.guild != null) { - Stream named = context.guild!.searchMembersGateway(word, limit: 800000); - - List usernameExact = []; - List nickExact = []; - - List usernameCaseInsensitive = []; - List nickCaseInsensitive = []; - - List usernameStart = []; - List nickStart = []; - - await for (final member in named) { - IUser user = await member.user.getOrDownload(); - - if (user.username == word) { - usernameExact.add(member); - } - if (user.username.toLowerCase() == word.toLowerCase()) { - usernameCaseInsensitive.add(member); - } - if (user.username.toLowerCase().startsWith(word.toLowerCase())) { - usernameStart.add(member); - } - - if (member.nickname != null) { - if (member.nickname! == word) { - nickExact.add(member); - } - if (member.nickname!.toLowerCase() == word.toLowerCase()) { - nickCaseInsensitive.add(member); - } - if (member.nickname!.toLowerCase().startsWith(word.toLowerCase())) { - nickStart.add(member); - } - } - } - - for (final list in [ - usernameExact, - nickExact, - usernameCaseInsensitive, - nickCaseInsensitive, - usernameStart, - nickStart - ]) { - if (list.length == 1) { - return list.first; - } - } - } - return null; -} - -/// A converter that converts input to an [IMember]. -/// -/// This will first attempt to parse the input to a snowflake which will then be converted to an -/// [IMember]. If this fails, the member will be looked up by name. -/// -/// This converter has a Discord Slash Command Argument Type of [CommandOptionType.user]. -const Converter memberConverter = FallbackConverter( - [ - // Get member from mention or snowflake. - CombineConverter(snowflakeConverter, snowflakeToMember), - // Get member by name or nickname - Converter(convertMember), - ], - type: CommandOptionType.user, -); - -Future snowflakeToUser(Snowflake snowflake, IChatContext context) async { - IUser? cached = context.client.users[snowflake]; - if (cached != null) { - return cached; - } - - if (context.client is INyxxRest) { - try { - return await (context.client as INyxxRest).httpEndpoints.fetchUser(snowflake); - } on IHttpResponseError { - return null; - } - } - - return null; -} - -FutureOr memberToUser(IMember member, IChatContext context) => member.user.getOrDownload(); - -FutureOr convertUser(StringView view, IChatContext context) { - String word = view.getWord(); - - if (context.channel.channelType == ChannelType.dm || - context.channel.channelType == ChannelType.groupDm) { - List exact = []; - List caseInsensitive = []; - List start = []; - - for (final user in [ - ...(context.channel as IDMChannel).participants, - if (context.client is INyxxRest) (context.client as INyxxRest).self, - ]) { - if (user.username == word) { - exact.add(user); - } - - if (user.username.toLowerCase() == word.toLowerCase()) { - caseInsensitive.add(user); - } - - if (user.username.toLowerCase().startsWith(word.toLowerCase())) { - start.add(user); - } - - for (final list in [exact, caseInsensitive, start]) { - if (list.length == 1) { - return list.first; - } - } - } - } - return null; -} - -/// A converter that converts input to an [IUser]. -/// -/// This will first attempt to parse the input to a snowflake which will then be converted to an -/// [IUser]. If this fails, the input will be parsed as an [IMember] which will then be converted to -/// an [IUser]. If this fails, the user will be looked up by name. -/// -/// This converter has a Discord Slash Command Argument Type of [CommandOptionType.user]. -const Converter userConverter = FallbackConverter( - [ - CombineConverter(snowflakeConverter, snowflakeToUser), - CombineConverter(memberConverter, memberToUser), - Converter(convertUser), - ], - type: CommandOptionType.user, -); - -IGuildChannel? snowflakeToGuildChannel(Snowflake snowflake, IChatContext context) { - if (context.guild != null) { - try { - return context.guild!.channels.firstWhere((channel) => channel.id == snowflake); - } on StateError { - return null; - } - } - - return null; -} - -IGuildChannel? convertGuildChannel(StringView view, IChatContext context) { - if (context.guild != null) { - String word = view.getQuotedWord(); - - List caseInsensitive = []; - List partial = []; - - for (final channel in context.guild!.channels) { - if (channel.name.toLowerCase() == word.toLowerCase()) { - caseInsensitive.add(channel); - } - if (channel.name.toLowerCase().startsWith(word.toLowerCase())) { - partial.add(channel); - } - } - - for (final list in [caseInsensitive, partial]) { - if (list.length == 1) { - return list.first; - } - } - } - - 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`. -/// -/// -/// 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; -/// - [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 - FutureOr?> Function(AutocompleteContext)? get autocompleteCallback => - null; - - @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] 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] 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] and is -/// set to accept channels of type [ChannelType.voice]. -const GuildChannelConverter voiceGuildChannelConverter = GuildChannelConverter([ - ChannelType.voice, -]); - -/// A converter that converts input to an [ICategoryGuildChannel]. -/// -/// This will first attempt to parse the input as a [Snowflake] that will then be converted to an -/// [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] and it -/// set to accept channels of type [ChannelType.category]. -const GuildChannelConverter categoryGuildChannelConverter = - GuildChannelConverter([ - ChannelType.category, -]); - -/// A converter that converts input to an [IStageVoiceGuildChannel]. -/// -/// This will first attempt to parse the input as a [Snowflake] that will then be converted to an -/// [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] 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) { - IRole? cached = context.guild!.roles[snowflake]; - if (cached != null) { - return cached; - } - - try { - return context.guild!.fetchRoles().firstWhere((role) => role.id == snowflake); - } on StateError { - return null; - } - } - - return null; -} - -FutureOr convertRole(StringView view, IChatContext context) async { - String word = view.getQuotedWord(); - if (context.guild != null) { - Stream roles = context.guild!.fetchRoles(); - - List exact = []; - List caseInsensitive = []; - List partial = []; - - await for (final role in roles) { - if (role.name == word) { - exact.add(role); - } - if (role.name.toLowerCase() == word.toLowerCase()) { - caseInsensitive.add(role); - } - if (role.name.toLowerCase().startsWith(word.toLowerCase())) { - partial.add(role); - } - } - - for (final list in [exact, caseInsensitive, partial]) { - if (list.length == 1) { - return list.first; - } - } - } - return null; -} - -/// A converter that converts input to an [IRole]. -/// -/// This will first attempt to parse the input as a snowflake that will then be converted to an -/// [IRole]. If this fails, then the role will be looked up by name in the current guild. -/// -/// This converter has a Discord Slash Command argument type of [CommandOptionType.role]. -const Converter roleConverter = FallbackConverter( - [ - CombineConverter(snowflakeConverter, snowflakeToRole), - Converter(convertRole), - ], - type: CommandOptionType.role, -); - -/// A converter that converts input to a [Mentionable]. -/// -/// This will first attempt to convert the input as a member, then as a role. -/// -/// This converter has a Discord Slash Command argument type of [CommandOptionType.mentionable]. -const Converter mentionableConverter = FallbackConverter( - [ - memberConverter, - roleConverter, - ], - type: CommandOptionType.mentionable, -); - -IAttachment? snowflakeToAttachment(Snowflake id, IChatContext context) { - Iterable? attachments; - if (context is InteractionChatContext) { - attachments = context.interaction.resolved?.attachments ?? []; - } else if (context is MessageChatContext) { - attachments = context.message.attachments; - } - - if (attachments == null) { - return null; - } - - try { - return attachments.singleWhere((attachment) => attachment.id == id); - } on StateError { - return null; - } -} - -IAttachment? convertAttachment(StringView view, IChatContext context) { - String fileName = view.getQuotedWord(); - - Iterable? attachments; - if (context is InteractionChatContext) { - attachments = context.interaction.resolved?.attachments; - } else if (context is MessageChatContext) { - attachments = context.message.attachments; - } - - if (attachments == null) { - return null; - } - - Iterable exactMatch = attachments.where( - (attachment) => attachment.filename == fileName, - ); - - Iterable caseInsensitive = attachments.where( - (attachment) => attachment.filename.toLowerCase() == fileName.toLowerCase(), - ); - - Iterable partialMatch = attachments.where( - (attachment) => attachment.filename.toLowerCase().startsWith(fileName.toLowerCase()), - ); - - for (final list in [exactMatch, caseInsensitive, partialMatch]) { - if (list.length == 1) { - return list.first; - } - } - - return null; -} - -/// A converter that converts input to an [IAttachment]. -/// -/// This will first attempt to parse the input to a snowflake that will then be resolved as the ID -/// of one of the attachments in the message or interaction. If this fails, then the attachment will -/// be looked up by name. -/// -/// This converter has a Discord Slash Command argument type of [CommandOptionType.attachment]. -const Converter attachmentConverter = FallbackConverter( - [ - CombineConverter(snowflakeConverter, snowflakeToAttachment), - Converter(convertAttachment), - ], - type: CommandOptionType.attachment, -); - /// Apply a converter to an input and return the result. /// /// - [commands] is the instance of [CommandsPlugin] used to retrieve the appropriate converter; @@ -928,24 +136,26 @@ const Converter attachmentConverter = FallbackConverter( /// the converter to use. /// /// You might also be interested in: -/// - [ICommand.invoke], which parses multiple arguments and executes a command. -Future parse( +/// - [Command.invoke], which parses multiple arguments and executes a command. +Future parse( CommandsPlugin commands, - IChatContext context, + ContextData context, StringView toParse, - Type expectedType, { - Converter? converterOverride, + RuntimeType expectedType, { + Converter? converterOverride, }) async { - Converter? converter = converterOverride ?? commands.getConverter(expectedType); + Converter? converter = converterOverride ?? commands.getConverter(expectedType); if (converter == null) { - throw NoConverterException(expectedType, context); + throw NoConverterException(expectedType); } + StringView originalInput = toParse.copy(); + try { - dynamic parsed = await converter.convert(toParse, context); + T? parsed = await converter.convert(toParse, context); if (parsed == null) { - throw BadInputException('Could not parse input $context to type "$expectedType"', context); + throw ConverterFailedException(converter, originalInput, context); } return parsed; diff --git a/lib/src/converters/fallback.dart b/lib/src/converters/fallback.dart new file mode 100644 index 0000000..f01e487 --- /dev/null +++ b/lib/src/converters/fallback.dart @@ -0,0 +1,158 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import '../context/autocomplete_context.dart'; +import '../context/base.dart'; +import '../util/view.dart'; +import 'converter.dart'; + +/// A converter that successively tries a list of converters until one succeeds. +/// +/// Given three converters *a*, *b* and *c*, a [FallbackConverter] will first try to convert the +/// input using *a*, then, if *a* failed, using *b*, then, if *b* failed, using *c*. If all of *a*, +/// *b* and *c* fail, then the [FallbackConverter] will also fail. If at least one of *a*, *b* or +/// *c* succeed, the [FallbackConverter] will return the result of that conversion. +/// +/// You might also be interested in: +/// - [CombineConverter], for further processing the output of another converter. +class FallbackConverter implements Converter { + /// The converters this [FallbackConverter] will attempt to use. + final Iterable> converters; + + @override + final void Function(CommandOptionBuilder)? processOptionCallback; + + @override + final FutureOr>?> Function(AutocompleteContext)? + autocompleteCallback; + + final Iterable>? _choices; + final CommandOptionType? _type; + + final FutureOr Function(T)? _toSelectMenuOption; + + final FutureOr Function(T)? _toButton; + + @override + RuntimeType get output => RuntimeType(); + + /// Create a new [FallbackConverter]. + const FallbackConverter( + this.converters, { + Iterable>? choices, + CommandOptionType? type, + this.processOptionCallback, + this.autocompleteCallback, + FutureOr Function(T)? toSelectMenuOption, + FutureOr Function(T)? toButton, + }) : _choices = choices, + _type = type, + _toSelectMenuOption = toSelectMenuOption, + _toButton = toButton; + + @override + Iterable>? get choices { + if (_choices != null) { + return _choices; + } + + List> allChoices = []; + + for (final converter in converters) { + Iterable>? converterChoices = converter.choices; + + if (converterChoices == null) { + return null; + } + + for (final choice in converterChoices) { + CommandOptionChoiceBuilder existing = + allChoices.singleWhere((element) => element.name == choice.name, orElse: () => choice); + + if (existing.value != choice.value) { + return null; + } else if (identical(choice, existing)) { + allChoices.add(choice); + } + } + } + + if (allChoices.isEmpty || allChoices.length > 25) { + return null; + } + + return allChoices; + } + + @override + CommandOptionType get type { + if (_type != null) { + return _type!; + } + + Iterable converterTypes = converters.map((converter) => converter.type); + + if (converterTypes.every((element) => element == converterTypes.first)) { + return converterTypes.first; + } + + return CommandOptionType.string; + } + + @override + FutureOr Function(StringView view, ContextData context) get convert => (view, context) async { + StringView? used; + T? ret = await converters.fold(Future.value(null), (previousValue, element) async { + if (await previousValue != null) { + return await previousValue; + } + + used = view.copy(); + return await element.convert(used!, context); + }); + + if (used != null) { + view.history + ..clear() + ..addAll(used!.history); + + view.index = used!.index; + } + + return ret; + }; + + @override + FutureOr Function(T)? get toSelectMenuOption { + if (_toSelectMenuOption != null) { + return _toSelectMenuOption; + } + + for (final converter in converters) { + if (converter.toSelectMenuOption is FutureOr Function(T)) { + return converter.toSelectMenuOption; + } + } + + return null; + } + + @override + FutureOr Function(T)? get toButton { + if (_toButton != null) { + return _toButton; + } + + for (final converter in converters) { + if (converter.toButton is FutureOr Function(T)) { + return converter.toButton; + } + } + + return null; + } + + @override + String toString() => 'FallbackConverter<$T>[converters=${List.of(converters)}]'; +} diff --git a/lib/src/converters/simple.dart b/lib/src/converters/simple.dart new file mode 100644 index 0000000..6c78d71 --- /dev/null +++ b/lib/src/converters/simple.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:fuzzywuzzy/fuzzywuzzy.dart' as fuzzy; +import 'package:nyxx/nyxx.dart'; + +import '../context/autocomplete_context.dart'; +import '../context/base.dart'; +import '../util/view.dart'; +import 'converter.dart'; + +/// A basic wrapper around a converter, providing an easy way to create custom converters. +/// +/// This class allows you to create a custom converter by simply specifying a function to retrieve +/// the available elements to convert and a function to convert elements to a [String] displayed to +/// the user. +/// +/// This converter provides the core converter functionality as well as autocompletion and, if +/// [SimpleConverter.fixed] is used, command parameter choices. +/// +/// You might also be interested in: +/// - [Converter], the base class for creating custom converters. +/// - [FallbackConverter] and [CombineConverter], two other helpers for creating custom converters. +abstract class SimpleConverter implements Converter { + /// A function called to retrieve the available elements to convert. + /// + /// This should return an iterable of all the instances of `T` that this converter should allow to + /// be returned. It does not have to always return the same number of instances, and will be + /// called for each new operation requested from this converter. + FutureOr> Function(ContextData) get provider; + + /// A function called to convert elements into [String]s that can be displayed in the Discord + /// client. + /// + /// This function should return a unique textual representation for each element [provider] + /// returns. It should be consistent (that is, if `a == b`, `stringify(a) == stringify(b)`) or the + /// converter might fail unexpectedly. + String Function(T) get stringify; + + /// A function called if this converter fails to convert the input. + /// + /// You can provide additional logic here to convert inputs that would otherwise fail. When this + /// function is called, it can either return an instance of `T` which will be returned from this + /// converter or `null`, in which case the converter will fail. + final T? Function(StringView, ContextData)? reviver; + + /// The sensitivity of this converter. + /// + /// The sensitivity of a [SimpleConverter] determines how similar the input must be to the + /// [String] returned by [stringify] for this converter to succeed. + /// + /// If [sensitivity] is `0`, this converter will always succeed with the element most similar to + /// the input provided [provider] at least one element. If [sensitivity] is `100`, this converter + /// will only succeed if the input matches exactly with one of the elements. + final int sensitivity; + + @override + RuntimeType get output => RuntimeType(); + + @override + CommandOptionType get type => CommandOptionType.string; + + final FutureOr Function(T)? _toSelectMenuOption; + final FutureOr Function(T)? _toButton; + + const SimpleConverter._({ + required this.sensitivity, + this.reviver, + FutureOr Function(T)? toSelectMenuOption, + FutureOr Function(T)? toButton, + }) : _toSelectMenuOption = toSelectMenuOption, + _toButton = toButton; + + /// Create a new [SimpleConverter]. + /// + /// If you want this instance to be `const` (for use with @[UseConverter]), [provider] and + /// [stringify] must be top-level or static functions. Function literals are not `const`, so they + /// cannot be used in a constant creation expression. + const factory SimpleConverter({ + required FutureOr> Function(ContextData) provider, + required String Function(T) stringify, + int sensitivity, + T? Function(StringView, ContextData) reviver, + }) = _DynamicSimpleConverter; + + /// Create a new [SimpleConverter] which converts an unchanging number of elements. + /// + /// This differs from a normal [SimpleConverter] in that it will use parameter choices instead + /// of autocompletion if the number of elements is small enough. If the number of elements is not + /// small enough to use choices, the normal [SimpleConverter] behavior is used instead. + const factory SimpleConverter.fixed({ + required List elements, + required String Function(T) stringify, + int sensitivity, + T? Function(StringView, ContextData) reviver, + }) = _FixedSimpleConverter; + + @override + Future>>? Function(AutocompleteContext)? + get autocompleteCallback => (context) async { + List choices = (await provider(context)).map(stringify).toList(); + + if (context.currentValue.isEmpty) { + return choices.take(25).map((e) => CommandOptionChoiceBuilder(name: e, value: e)); + } + + return fuzzy + .extractTop( + query: context.currentValue, + choices: choices, + limit: 25, + cutoff: sensitivity, + ) + .map((e) => CommandOptionChoiceBuilder(name: e.choice, value: e.choice)); + }; + + @override + Future Function(StringView view, ContextData context) get convert => (view, context) async { + try { + return fuzzy + .extractOne( + query: view.getQuotedWord(), + choices: (await provider(context)).toList(), + getter: stringify, + cutoff: sensitivity, + ) + .choice; + } on StateError { + // No elements matched query, try to revive the input. + // Make sure to undo the call to `getQuotedWord()`. + return reviver?.call(view..undo(), context); + } + }; + + @override + FutureOr Function(T)? get toSelectMenuOption => + _toSelectMenuOption ?? + (element) { + String value = stringify(element); + + return SelectMenuOptionBuilder( + label: value, value: value, description: null, emoji: null, isDefault: null); + }; + + @override + FutureOr Function(T)? get toButton => + _toButton ?? + (element) => ButtonBuilder( + style: ButtonStyle.primary, + label: stringify(element), + customId: '', + ); + + @override + Iterable>? get choices => null; + + @override + void Function(CommandOptionBuilder)? get processOptionCallback => null; +} + +class _DynamicSimpleConverter extends SimpleConverter { + @override + final FutureOr> Function(ContextData) provider; + + @override + final String Function(T) stringify; + + const _DynamicSimpleConverter({ + required this.provider, + required this.stringify, + super.sensitivity = 50, + super.reviver, + super.toSelectMenuOption, + super.toButton, + }) : super._(); +} + +class _FixedSimpleConverter extends SimpleConverter { + final List elements; + + @override + final String Function(T) stringify; + + const _FixedSimpleConverter({ + required this.elements, + required this.stringify, + super.sensitivity = 50, + super.reviver, + super.toSelectMenuOption, + super.toButton, + }) : super._(); + + @override + Iterable Function(ContextData) get provider => (_) => elements; + + @override + Future>>? Function(AutocompleteContext)? + get autocompleteCallback => + // Don't autocomplete if we have less than 25 elements because we will use choices instead. + elements.length > 25 ? super.autocompleteCallback : null; + + @override + Iterable>? get choices => + // Only use choices if we have less than 26 elements (maximum of 25 choices). + elements.length <= 25 + ? elements.map(stringify).map((e) => CommandOptionChoiceBuilder(name: e, value: e)) + : null; +} diff --git a/lib/src/errors.dart b/lib/src/errors.dart index 7e17e8c..8c888e4 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -1,21 +1,12 @@ -// 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:runtime_type/runtime_type.dart'; import 'checks/checks.dart'; import 'context/autocomplete_context.dart'; +import 'context/base.dart'; import 'context/chat_context.dart'; -import 'context/context.dart'; +import 'context/component_context.dart'; +import 'converters/converter.dart'; +import 'util/util.dart'; import 'util/view.dart'; /// The base class for exceptions thrown by nyxx_commands. @@ -34,6 +25,25 @@ class CommandsException implements Exception { /// users. Checking the type of the error and reacting accordingly is recommended. String message; + /// The stack trace at the point where this exception was first thrown. + /// + /// Might be unset if nyxx_commands has not yet handled this exception. The `stackTrace = ...` + /// setter should not be called if this is already non-null, so you should avoid calling it unless + /// you are creating exceptions yourself. + StackTrace? get stackTrace => _stackTrace; + + set stackTrace(StackTrace? stackTrace) { + if (this.stackTrace != null) { + // Use a native error instead of one from nyxx_commands that could potentially lead to an + // infinite error loop + throw StateError('Cannot set CommandsException.stackTrace if it is already set'); + } + + _stackTrace = stackTrace; + } + + StackTrace? _stackTrace; + /// Create a new [CommandsException]. CommandsException(this.message); @@ -41,13 +51,41 @@ class CommandsException implements Exception { String toString() => 'Command Exception: $message'; } -/// An exception that occurred during the execution of a command. -class CommandInvocationException extends CommandsException { +/// An exception that can be attached to a known context. +/// +/// Subclasses of this exception are generally thrown during the processing of [context]. +class ContextualException extends CommandsException { /// The context in which the exception occurred. - final IContext context; + final ContextData context; + + /// Create a new [ContextualException]. + ContextualException(super.message, this.context); +} + +/// An exception thrown when an interaction on a component created by nyxx_commands was received but +/// was not handled. +class UnhandledInteractionException extends CommandsException implements ContextualException { + @override + final ComponentContext context; + + /// The [ComponentId] of the component that was interacted with. + final ComponentId componentId; + + /// The reason this interaction was not handled. + ComponentIdStatus get reason => componentId.status; + + /// Create a new [UnhandledInteractionException]. + UnhandledInteractionException(this.context, this.componentId) + : super('Unhandled interaction: ${componentId.status}'); +} + +/// An exception that occurred during the execution of a command. +class CommandInvocationException extends CommandsException implements ContextualException { + @override + final CommandContext context; /// Create a new [CommandInvocationException]. - CommandInvocationException(String message, this.context) : super(message); + CommandInvocationException(super.message, this.context); } /// A wrapper class for an exception that caused an autocomplete event to fail. @@ -61,18 +99,18 @@ 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. + /// by accessing the [AutocompleteContext.interaction] and calling + /// [ApplicationCommandAutocompleteInteraction.respond] with the default options. final AutocompleteContext context; /// The exception that occurred. - final Exception exception; + final Object 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. +/// A wrapper class for an exception that was thrown inside the [Command.execute] callback. /// /// This generally indicates incorrect or incomplete code inside a command callback, and the /// developer should try to identify and fix the issue. @@ -80,23 +118,59 @@ class AutocompleteFailedException extends CommandsException { /// If you are throwing exceptions to indicate command failure, consider using [Check]s instead. class UncaughtException extends CommandInvocationException { /// The exception that occurred. - final Exception exception; + final Object exception; /// Create a new [UncaughtException]. - UncaughtException(this.exception, IContext context) : super(exception.toString(), context); + UncaughtException(this.exception, CommandContext context) : super(exception.toString(), context); +} + +/// An exception thrown when an interaction times out in a command. +/// +/// This is the exception thrown by [InteractiveContext.getButtonPress], +/// [InteractiveContext.getSelection] and other methods that might time out. +class InteractionTimeoutException extends CommandInvocationException { + /// Create a new [InteractionTimeoutException]. + InteractionTimeoutException(super.message, super.context); +} + +/// An exception thrown by nyxx_commands to indicate misuse of the library. +class UncaughtCommandsException extends UncaughtException { + @override + final StackTrace stackTrace; + + /// Create a new [UncaughtCommandsException]. + UncaughtCommandsException(String message, CommandContext context) + : stackTrace = StackTrace.current, + super(CommandsException(message), context); } /// An exception that occurred due to an invalid input from the user. /// /// This generally indicates that nyxx_commands was unable to parse the user's input. -class BadInputException extends CommandInvocationException { +class BadInputException extends ContextualException { /// Create a new [BadInputException]. - BadInputException(String message, IChatContext context) : super(message, context); + BadInputException(super.message, super.context); +} + +/// An exception thrown when a converter fails to convert user input. +class ConverterFailedException extends BadInputException { + /// The converter that failed. + final Converter failed; + + /// The [StringView] representing the arguments before the converter was invoked. + final StringView input; + + /// Create a new [ConverterFailedException]. + ConverterFailedException(this.failed, this.input, ContextData context) + : super( + 'Could not parse input $input to type "${failed.type}"', + context, + ); } -/// An exception thrown when the end of userr input is encountered before all the required arguments +/// An exception thrown when the end of user input is encountered before all the required arguments /// of a [ChatCommand] have been parsed. -class NotEnoughArgumentsException extends BadInputException { +class NotEnoughArgumentsException extends CommandInvocationException implements BadInputException { /// Create a new [NotEnoughArgumentsException]. NotEnoughArgumentsException(MessageChatContext context) : super( @@ -112,7 +186,7 @@ class CheckFailedException extends CommandInvocationException { final AbstractCheck failed; /// Create a new [CheckFailedException]. - CheckFailedException(this.failed, IContext context) + CheckFailedException(this.failed, CommandContext context) : super('Check "${failed.name}" failed', context); } @@ -120,13 +194,12 @@ class CheckFailedException extends CommandInvocationException { /// /// You might also be interested in: /// - [CommandsPlugin.addConverter], for adding your own [Converter]s to your bot. -class NoConverterException extends CommandInvocationException { +class NoConverterException extends CommandsException { /// The type that the converter was requested for. - final Type expectedType; + final RuntimeType expectedType; /// Create a new [NoConverterException]. - NoConverterException(this.expectedType, IChatContext context) - : super('No converter found for type "$expectedType"', context); + NoConverterException(this.expectedType) : super('No converter found for type "$expectedType"'); } /// An exception thrown when a message command matching [CommandsPlugin.prefix] is found, but no @@ -173,5 +246,5 @@ class CommandsError extends Error { /// An error that occurred during registration of a command. class CommandRegistrationError extends CommandsError { /// Create a new [CommandRegistrationError]. - CommandRegistrationError(String message) : super(message); + CommandRegistrationError(super.message); } diff --git a/lib/src/event_manager.dart b/lib/src/event_manager.dart new file mode 100644 index 0000000..4f836f7 --- /dev/null +++ b/lib/src/event_manager.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import 'commands.dart'; +import 'commands/chat_command.dart'; +import 'commands/message_command.dart'; +import 'commands/user_command.dart'; +import 'context/autocomplete_context.dart'; +import 'context/base.dart'; +import 'context/chat_context.dart'; +import 'context/component_context.dart'; +import 'errors.dart'; +import 'util/util.dart'; +import 'util/view.dart'; + +/// A handler for events incoming from nyxx and nyxx_interactions, and listeners associated with +/// those events. +/// +/// This class will listen to events from nyxx and nyxx_interactions, transform them into a +/// nyxx_commands class using [CommandsPlugin.contextManager] if needed and emit them to the correct +/// command handler or listener. +class EventManager { + /// The [CommandsPlugin] this event manager is associated with. + final CommandsPlugin commands; + + final Map, + Map>> _listeners = {}; + + /// Create a new [EventManager]. + EventManager(this.commands); + + Future _nextComponentEvent(ComponentId id) { + assert(T != dynamic); + + final type = RuntimeType(); + _listeners[type] ??= {}; + + final completer = Completer(); + _listeners[type]![id] ??= completer; + + if (id.expiresAt != null) { + final expiresIn = id.expiresIn!; + + Timer(expiresIn, () { + if (!completer.isCompleted) { + completer.completeError( + TimeoutException( + 'Timed out waiting for interaction on $id', + expiresIn, + ), + StackTrace.current, + ); + } + + stopListeningFor(id); + }); + } + + return completer.future; + } + + Future _processComponentEvent( + MessageComponentInteraction interaction, + FutureOr Function(MessageComponentInteraction) converter, + ) async { + final id = ComponentId.parse(interaction.data.customId); + + if (id == null) { + return; + } + + U context = await converter(interaction); + + if (id.status != ComponentIdStatus.ok) { + throw UnhandledInteractionException(context, id); + } + + if (id.allowedUser != null && context.user.id != id.allowedUser) { + throw UnhandledInteractionException(context, id.withStatus(ComponentIdStatus.wrongUser)); + } + + final listenerType = RuntimeType(); + final completer = _listeners[listenerType]?[id]; + + if (completer == null) { + throw UnhandledInteractionException(context, id.withStatus(ComponentIdStatus.noHandlerFound)); + } + + completer.complete(context); + stopListeningFor(id); + } + + /// Get a future that completes with a context representing the next interaction on the button + /// with id [id]. + /// + /// If [id] has an expiration time, the future will complete with an error once that time is + /// elapsed. + Future nextButtonEvent(ComponentId id) => _nextComponentEvent(id); + + /// Get a future that completes with a context representing the next interaction on the + /// select menu with id [id]. + /// + /// If [id] has an expiration time, the future will complete with an error once that time is + /// elapsed. + Future>> nextSelectMenuEvent(ComponentId id) => + _nextComponentEvent(id); + + /// Stop listening for events from the component with id [id]. + /// + /// Listeners waiting for events from this component will not be completed. + void stopListeningFor(ComponentId id) { + for (final listenerMap in _listeners.values) { + listenerMap.remove(id); + } + } + + /// The handler for button [MessageComponentInteraction]s. + /// + /// Attach to [NyxxGateway.onMessageComponentInteraction] where the component is a button. + Future processButtonInteraction(MessageComponentInteraction interaction) => + _processComponentEvent( + interaction, + commands.contextManager.createButtonComponentContext, + ); + + /// The handler for select menu [MessageComponentInteraction]s. + /// + /// Attach to [NyxxGateway.onMessageComponentInteraction] where the component is a select menu. + Future processSelectMenuInteraction(MessageComponentInteraction interaction) => + _processComponentEvent>>( + interaction, + (event) => commands.contextManager.createSelectMenuContext(event, event.data.values!), + ); + + /// A handler for [MessageCreateEvent]s. + /// + /// Attach to [NyxxGateway.onMessageCreate]. + Future processMessageCreateEvent(MessageCreateEvent event) async { + if (commands.prefix == null) return; + + final message = event.message; + + Pattern prefix = await commands.prefix!(event); + StringView view = StringView(message.content); + + Match? matchedPrefix = view.skipPattern(prefix); + + if (matchedPrefix != null) { + ChatContext context = await commands.contextManager + .createMessageChatContext(message, view, matchedPrefix.group(0)!); + + if (message.author is User && + (message.author as User).isBot && + !context.command.resolvedOptions.acceptBotCommands!) { + return; + } + + if (message.author.id == await event.gateway.client.users.fetchCurrentUser() && + !context.command.resolvedOptions.acceptSelfCommands!) { + return; + } + + logger.fine('Invoking command ${context.command.name} from message $message'); + + await context.command.invoke(context); + } + } + + /// A handler for generic interaction contexts. + /// + /// This handler takes in a context created by another handler and executes the associated + /// command. + Future processInteractionCommand(InteractionCommandContext context) async { + if (context.command.resolvedOptions.autoAcknowledgeInteractions!) { + Duration? timeout = context.command.resolvedOptions.autoAcknowledgeDuration; + + if (timeout == null) { + final latency = context.client.httpHandler.realLatency; + timeout = const Duration(seconds: 3) - latency * 2; + } + + Timer(timeout, () async { + try { + await context.acknowledge(); + } on AlreadyAcknowledgedError { + // ignore: command has responded itself + } + }); + } + + logger.fine('Invoking command ${context.command.name} ' + 'from interaction ${context.interaction.token}'); + + await context.command.invoke(context); + } + + /// A handler for chat [ApplicationCommandInteraction]s where the command is a chat command. + /// + /// Attach to [NyxxGateway.onApplicationCommandInteraction] where the command is a chat command. + /// + /// [command] is the [ChatCommand] resolved to be the target of the interaction. + /// [options] are the options passed to the command in the [interaction], excluding subcommand + /// options. + Future processChatInteraction( + ApplicationCommandInteraction interaction, + List options, + ChatCommand command, + ) async => + processInteractionCommand( + await commands.contextManager.createInteractionChatContext( + interaction, + options, + command, + ), + ); + + /// A handler for chat [ApplicationCommandInteraction]s where the command is a user command. + /// + /// Attach to [NyxxGateway.onApplicationCommandInteraction] where the command is a user command. + Future processUserInteraction( + ApplicationCommandInteraction interactionEvent, + UserCommand command, + ) async => + processInteractionCommand( + await commands.contextManager.createUserContext(interactionEvent, command), + ); + + /// A handler for chat [ApplicationCommandInteraction]s where the command is a message command. + /// + /// Attach to [NyxxGateway.onApplicationCommandInteraction] where the command is a message + /// command. + Future processMessageInteraction( + ApplicationCommandInteraction interactionEvent, + MessageCommand command, + ) async => + processInteractionCommand( + await commands.contextManager.createMessageContext(interactionEvent, command), + ); + + /// A handler for [ApplicationCommandAutocompleteInteraction]s. + /// + /// Attach to [NyxxGateway.onApplicationCommandAutocompleteInteraction]. + /// + /// [callback] is the autocompletion callback for the focused option. + /// [command] is the command the interaction is targeting. + Future processAutocompleteInteraction( + ApplicationCommandAutocompleteInteraction interactionEvent, + FutureOr>?> Function(AutocompleteContext) callback, + ChatCommand command, + ) async { + AutocompleteContext context = + await commands.contextManager.createAutocompleteContext(interactionEvent, command); + + try { + Iterable>? choices = await callback(context); + + interactionEvent.respond(choices?.toList() ?? []); + } catch (e) { + throw AutocompleteFailedException(e, context); + } + } +} diff --git a/lib/src/mirror_utils/compiled.dart b/lib/src/mirror_utils/compiled.dart index ce91061..be1fc33 100644 --- a/lib/src/mirror_utils/compiled.dart +++ b/lib/src/mirror_utils/compiled.dart @@ -1,180 +1,15 @@ -// 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 '../errors.dart'; +import '../util/util.dart'; +import 'mirror_utils.dart'; -import 'package:nyxx_commands/nyxx_commands.dart'; -import 'package:nyxx_commands/src/mirror_utils/mirror_utils.dart'; -import 'package:nyxx_commands/src/util/util.dart'; - -Map? _typeTree; -Map? _typeMappings; Map? _functionData; -bool isAssignableTo(Type instance, Type target) { - if (_typeTree == null || _typeMappings == null) { - throw CommandsError('Type data was not correctly loaded. Did you compile the wrong file?'); - } - - int? instanceId = _typeMappings?[instance]; - int? targetId = _typeMappings?[target]; - - if (instanceId == null) { - throw CommandsException('Couldnt find type data for type $instance'); - } else if (targetId == null) { - throw CommandsException('Couldnt find type data for type $target'); - } - - return _isAAssignableToB(instanceId, targetId, _typeTree!); -} - -bool _isAAssignableToB(int aId, int bId, Map typeTree) { - TypeData a = typeTree[aId]!; - TypeData b = typeTree[bId]!; - - // Identical - if (a.id == b.id) { - // x => x - return true; - } - - // Never - if (b is NeverTypeData || a is NeverTypeData) { - // * => Never || Never => * - return false; - } - - // Dynamic and void - if (b is VoidTypeData) { - // * - {Never} => void - return true; - } - - if (a is VoidTypeData) { - // void => * - {void, Never} - return false; - } - - if (b is DynamicTypeData) { - // * - {void, Never} => dynamic - return true; - } - - if (a is DynamicTypeData) { - // dynamic => * - {void, Never, dynamic} - return false; - } - - // Object to function - if (a is! FunctionTypeData && b is FunctionTypeData) { - // * - {Function} => Function - return false; - } - - // Object to object - if (a is InterfaceTypeData && b is InterfaceTypeData) { - if (a.strippedId == b.strippedId) { - // A and B are the same class with different type arguments. Check if the type arguments - // are subtypes. - for (int i = 0; i < a.typeArguments.length; i++) { - if (!_isAAssignableToB(a.typeArguments[i], b.typeArguments[i], typeTree)) { - return false; - } - } - - return b.isNullable || !a.isNullable; - } else { - // A and B are different classes. Check if one of A's supertypes is assignable to B - for (final superId in a.superClasses) { - if (_isAAssignableToB(superId, bId, typeTree)) { - return true; - } - } - - return false; - } - } else if (a is FunctionTypeData && b is InterfaceTypeData) { - // Functions can only be assigned to [Object] and [Function] interface types - return (b.id == _typeMappings![Object]! || b.id == _typeMappings![Function]!) && - (b.isNullable || !a.isNullable); - } else if (a is InterfaceTypeData && b is FunctionTypeData) { - // Objects cannot be assigned to functions - return false; - } else if (a is FunctionTypeData && b is FunctionTypeData) { - if (a.positionalParameterTypes.length > b.positionalParameterTypes.length) { - return false; - } - - if (b.requiredPositionalParametersCount > a.requiredPositionalParametersCount) { - return false; - } - - // Parameter types can be widened but not narrowed - for (int i = 0; i < a.positionalParameterTypes.length; i++) { - if (!_isAAssignableToB( - a.positionalParameterTypes[i], b.positionalParameterTypes[i], typeTree)) { - return false; - } - } - - for (final entry in a.requiredNamedParametersType.entries) { - String name = entry.key; - int id = entry.value; - - // Required named parameters in a must be in b, but can be either required or optional - int? matching = b.requiredNamedParametersType[name] ?? b.optionalNamedParametersType[name]; - - if (matching == null) { - return false; - } - - if (!_isAAssignableToB(id, matching, typeTree)) { - return false; - } - } - - for (final entry in a.optionalNamedParametersType.entries) { - String name = entry.key; - int id = entry.value; - - // Optional named parameters in a must also be optional in b - int? matching = b.optionalNamedParametersType[name]; - - if (matching == null) { - return false; - } - - if (!_isAAssignableToB(id, matching, typeTree)) { - return false; - } - } - - // Return type can be narrowed but not widened - if (!_isAAssignableToB(b.returnType, a.returnType, typeTree)) { - return false; - } - - return b.isNullable || !a.isNullable; - } - - throw CommandsException( - 'Unhandled assignability check between types ' - '"${a.runtimeType}" and "${b.runtimeType}"', - ); -} - FunctionData loadFunctionData(Function fn) { if (_functionData == null) { - throw CommandsError('Function data was not correctly loaded. Did you compile the wrong file?'); + throw CommandsError( + 'Function data was not correctly loaded. Did you compile the wrong file?' + '\nSee https://pub.dev/packages/nyxx_commands#compiling-nyxx-commands for more information.', + ); } dynamic id = idMap[fn]; @@ -182,18 +17,15 @@ FunctionData loadFunctionData(Function fn) { FunctionData? result = _functionData![id]; if (result == null) { - throw CommandsException("Couldn't load function data for function $fn"); + throw CommandsException( + "Couldn't load function data for function $fn" + '\nSee https://pub.dev/packages/nyxx_commands#compiling-nyxx-commands for more information.', + ); } return result; } -void loadData( - Map typeTree, - Map typeMappings, - Map functionData, -) { - _typeTree = typeTree; - _typeMappings = typeMappings; +void loadData(Map functionData) { _functionData = functionData; } diff --git a/lib/src/mirror_utils/function_data.dart b/lib/src/mirror_utils/function_data.dart index 0daec5d..fe42f46 100644 --- a/lib/src/mirror_utils/function_data.dart +++ b/lib/src/mirror_utils/function_data.dart @@ -1,37 +1,24 @@ -// 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 'dart:async'; -import 'package:nyxx_commands/src/context/autocomplete_context.dart'; -import 'package:nyxx_commands/src/converters/converter.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx/nyxx.dart'; + +import '../context/autocomplete_context.dart'; +import '../converters/converter.dart'; class FunctionData { - final List parametersData; + final List> parametersData; int get requiredParameters => parametersData.takeWhile((value) => !value.isOptional).length; const FunctionData(this.parametersData); } -class ParameterData { +class ParameterData { final String name; final Map? localizedNames; - final Type type; + final RuntimeType type; final bool isOptional; @@ -45,7 +32,8 @@ class ParameterData { final Converter? converterOverride; - final FutureOr?> Function(AutocompleteContext)? autocompleteOverride; + final FutureOr>?> Function(AutocompleteContext)? + autocompleteOverride; const ParameterData({ required this.name, diff --git a/lib/src/mirror_utils/mirror_utils.dart b/lib/src/mirror_utils/mirror_utils.dart index ea32a31..e66bd79 100644 --- a/lib/src/mirror_utils/mirror_utils.dart +++ b/lib/src/mirror_utils/mirror_utils.dart @@ -1,17 +1,5 @@ -// 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. - export 'compiled.dart' if (dart.library.mirrors) 'with_mirrors.dart'; export 'function_data.dart'; -export 'type_data.dart'; + +// Export types from other packages used in the nyxx_commands API. +export 'package:runtime_type/runtime_type.dart' show RuntimeType; diff --git a/lib/src/mirror_utils/type_data.dart b/lib/src/mirror_utils/type_data.dart deleted file mode 100644 index c84ed5e..0000000 --- a/lib/src/mirror_utils/type_data.dart +++ /dev/null @@ -1,109 +0,0 @@ -// 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. - -abstract class TypeData { - int get id; - - String get name; -} - -abstract class NullableTypeData { - bool get isNullable; -} - -class InterfaceTypeData implements TypeData, NullableTypeData { - @override - final int id; - - @override - final String name; - - final int strippedId; - - final List superClasses; - - final List typeArguments; - - @override - final bool isNullable; - - const InterfaceTypeData({ - required this.name, - required this.id, - required this.strippedId, - required this.superClasses, - required this.typeArguments, - required this.isNullable, - }); -} - -class FunctionTypeData implements TypeData, NullableTypeData { - @override - final int id; - - @override - final String name; - - final int returnType; - - final List positionalParameterTypes; - final int requiredPositionalParametersCount; - - final Map requiredNamedParametersType; - final Map optionalNamedParametersType; - - @override - final bool isNullable; - - const FunctionTypeData({ - required this.name, - required this.id, - required this.returnType, - required this.positionalParameterTypes, - required this.requiredPositionalParametersCount, - required this.requiredNamedParametersType, - required this.optionalNamedParametersType, - required this.isNullable, - }); -} - -class DynamicTypeData implements TypeData { - @override - final int id = 0; - - @override - final String name = 'dynamic'; - - const DynamicTypeData(); -} - -class VoidTypeData implements TypeData { - @override - final int id = 1; - - @override - final String name = 'void'; - - const VoidTypeData(); -} - -class NeverTypeData implements TypeData { - @override - final int id = 2; - - @override - final String name = 'Never'; - - const NeverTypeData(); -} diff --git a/lib/src/mirror_utils/with_mirrors.dart b/lib/src/mirror_utils/with_mirrors.dart index 193bcca..067ed9e 100644 --- a/lib/src/mirror_utils/with_mirrors.dart +++ b/lib/src/mirror_utils/with_mirrors.dart @@ -1,30 +1,24 @@ -// 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 'dart:async'; import 'dart:mirrors'; -import 'package:nyxx_commands/src/commands.dart'; -import 'package:nyxx_commands/src/mirror_utils/mirror_utils.dart'; -import 'package:nyxx_commands/nyxx_commands.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:runtime_type/mirrors.dart'; + +import '../commands.dart'; +import '../context/autocomplete_context.dart'; +import '../converters/converter.dart'; +import '../errors.dart'; +import '../util/util.dart'; +import 'mirror_utils.dart'; -bool isAssignableTo(Type instance, Type target) => - instance == target || reflectType(instance).isSubtypeOf(reflectType(target)); +final Map _cache = {}; FunctionData loadFunctionData(Function fn) { - List parametersData = []; + if (_cache.containsKey(fn)) { + return _cache[fn]!; + } + + List> parametersData = []; MethodMirror fnMirror = (reflect(fn) as ClosureMirror).function; @@ -56,8 +50,9 @@ FunctionData loadFunctionData(Function fn) { } // Get parameter type - Type type = + Type rawType = parameterMirror.type.hasReflectedType ? parameterMirror.type.reflectedType : dynamic; + RuntimeType type = rawType.toRuntimeType(); // Get parameter description (if any) @@ -104,7 +99,8 @@ FunctionData loadFunctionData(Function fn) { throw CommandRegistrationError('parameters may have at most one Autocomplete decorator'); } - FutureOr?> Function(AutocompleteContext)? autocompleteOverride; + FutureOr>?> Function(AutocompleteContext)? + autocompleteOverride; if (autocompleteAnnotations.isNotEmpty) { autocompleteOverride = autocompleteAnnotations.first.callback; } @@ -123,15 +119,11 @@ FunctionData loadFunctionData(Function fn) { )); } - return FunctionData(parametersData); + return _cache[fn] = FunctionData(parametersData); } -void loadData( - Map typeTree, - Map typeMappings, - Map functionData, -) { +void loadData(Map functionData) { if (const bool.fromEnvironment('dart.library.mirrors')) { - logger.info('Loading compiled function data when `dart:mirrors` is availible is unneeded'); + logger.info('Loading compiled function data when `dart:mirrors` is available is unneeded'); } } diff --git a/lib/src/options.dart b/lib/src/options.dart index 6b9e201..8043a42 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -1,26 +1,13 @@ -// 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_interactions/nyxx_interactions.dart'; +import 'package:nyxx/nyxx.dart'; import 'commands/chat_command.dart'; import 'commands/options.dart'; +import 'context/base.dart'; /// Options that modify how the [CommandsPlugin] instance works. /// /// You might also be interested in: -/// - [CommandOptions], the options for individual [IOptions] instances. +/// - [CommandOptions], the options for individual [Options] instances. class CommandsOptions implements CommandOptions { /// Whether to automatically log exceptions. /// @@ -32,11 +19,12 @@ class CommandsOptions implements CommandOptions { /// - [Logging], a plugin to automatically print logs to the console. final bool logErrors; - /// The [InteractionBackend] to use for creating the [IInteractions] instance. + /// Whether to infer the default command type. /// - /// If this is set to null, then a [WebsocketInteractionBackend] will automatically be created, - /// using the client the [CommandsPlugin] was added to as the client. - final InteractionBackend? backend; + /// If this is `true` and [type] is [CommandType.all], then the root command type used will be + /// [CommandType.slashOnly] if [CommandsPlugin.prefix] is not specified. If + /// [CommandsPlugin.prefix] is specified, the root command type will be left as-is. + final bool inferDefaultCommandType; @override final bool acceptBotCommands; @@ -48,19 +36,27 @@ class CommandsOptions implements CommandOptions { final bool autoAcknowledgeInteractions; @override - final bool hideOriginalResponse; + final ResponseLevel defaultResponseLevel; + + @override + final CommandType type; + + @override + final bool caseInsensitiveCommands; @override - final CommandType defaultCommandType; + final Duration? autoAcknowledgeDuration; /// Create a new set of [CommandsOptions]. const CommandsOptions({ this.logErrors = true, this.autoAcknowledgeInteractions = true, + this.autoAcknowledgeDuration, this.acceptBotCommands = false, this.acceptSelfCommands = false, - this.backend, - this.hideOriginalResponse = true, - this.defaultCommandType = CommandType.all, + this.defaultResponseLevel = ResponseLevel.public, + this.type = CommandType.all, + this.inferDefaultCommandType = true, + this.caseInsensitiveCommands = true, }); } diff --git a/lib/src/util/mixins.dart b/lib/src/util/mixins.dart index 487d58e..1e5f729 100644 --- a/lib/src/util/mixins.dart +++ b/lib/src/util/mixins.dart @@ -1,32 +1,29 @@ -// 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 'dart:async'; +import 'dart:math'; + +import 'package:nyxx/nyxx.dart'; import '../checks/checks.dart'; +import '../commands.dart'; import '../commands/chat_command.dart'; import '../commands/interfaces.dart'; import '../commands/options.dart'; -import '../context/context.dart'; +import '../context/base.dart'; +import '../context/component_context.dart'; +import '../context/modal_context.dart'; +import '../converters/converter.dart'; import '../errors.dart'; +import '../util/util.dart'; +import '../util/view.dart'; -mixin ParentMixin implements ICommandRegisterable { - ICommandGroup? _parent; +mixin ParentMixin implements CommandRegisterable { + CommandGroup? _parent; @override - ICommandGroup? get parent => _parent; + CommandGroup? get parent => _parent; @override - set parent(ICommandGroup? parent) { + set parent(CommandGroup? parent) { if (_parent != null) { throw CommandRegistrationError('Cannot register command "$name" again'); } @@ -34,7 +31,7 @@ mixin ParentMixin implements ICommandRegisterable { } } -mixin CheckMixin on ICommandRegisterable implements IChecked { +mixin CheckMixin on CommandRegisterable implements Checked { final List _checks = []; @override @@ -54,20 +51,22 @@ mixin CheckMixin on ICommandRegisterable implements IChec } } -mixin OptionsMixin on ICommandRegisterable implements IOptions { +mixin OptionsMixin on CommandRegisterable implements Options { @override CommandOptions get resolvedOptions { if (parent == null) { return options; } - CommandOptions parentOptions = parent is ICommandRegisterable - ? (parent as ICommandRegisterable).resolvedOptions + CommandOptions parentOptions = parent is CommandRegisterable + ? (parent as CommandRegisterable).resolvedOptions : parent!.options; - CommandType? defaultCommandType; - if (options.defaultCommandType != CommandType.def) { - defaultCommandType = options.defaultCommandType; + CommandType? parentType = parentOptions.type; + if (parent is CommandsPlugin) { + if ((parent as CommandsPlugin).prefix == null && parentType == CommandType.all) { + parentType = CommandType.slashOnly; + } } return CommandOptions( @@ -75,8 +74,752 @@ mixin OptionsMixin on ICommandRegisterable implements IOp options.autoAcknowledgeInteractions ?? parentOptions.autoAcknowledgeInteractions, acceptBotCommands: options.acceptBotCommands ?? parentOptions.acceptBotCommands, acceptSelfCommands: options.acceptSelfCommands ?? parentOptions.acceptSelfCommands, - hideOriginalResponse: options.hideOriginalResponse ?? parentOptions.hideOriginalResponse, - defaultCommandType: defaultCommandType ?? parentOptions.defaultCommandType, + defaultResponseLevel: options.defaultResponseLevel ?? parentOptions.defaultResponseLevel, + type: options.type ?? parentType, + autoAcknowledgeDuration: + options.autoAcknowledgeDuration ?? parentOptions.autoAcknowledgeDuration, + caseInsensitiveCommands: + options.caseInsensitiveCommands ?? parentOptions.caseInsensitiveCommands, + ); + } +} + +mixin InteractiveMixin implements InteractiveContext, ContextData { + @override + InteractiveContext? get parent => _parent; + + // Must narrow type to use [_nearestCommandContext]. + InteractiveMixin? _parent; + + @override + InteractiveContext? get delegate => _delegate; + InteractiveContext? _delegate; + + @override + InteractiveContext get latestContext => delegate?.latestContext ?? this; + + CommandContext get _nearestCommandContext { + if (this is CommandContext) { + return this as CommandContext; + } + + if (parent == null) { + // This generally happens when a context is created directly from the plugin's context manager + // and not from an existing context inside a command, which messes with functionality like + // parsing which requires an ICommandContext to invoke converters. + throw CommandsError( + 'Cannot use command functionality in a context created outside of a command', + ); + } + + return _parent!._nearestCommandContext; + } + + @override + Future awaitButtonPress(ComponentId componentId) async { + if (delegate != null) { + return delegate!.awaitButtonPress(componentId); + } + + try { + ButtonComponentContext context = await commands.eventManager.nextButtonEvent(componentId); + + context._parent = this; + _delegate = context; + + return context; + } on TimeoutException catch (e, s) { + throw InteractionTimeoutException( + 'Timed out waiting for button press on component $componentId', + _nearestCommandContext, + )..stackTrace = s; + } + } + + @override + Future> awaitSelection( + ComponentId componentId, { + Converter? converterOverride, + }) async { + if (delegate != null) { + return delegate!.awaitSelection( + componentId, + converterOverride: converterOverride, + ); + } + + SelectMenuContext> rawContext = + await commands.eventManager.nextSelectMenuEvent(componentId); + + SelectMenuContext context = await commands.contextManager.createSelectMenuContext( + rawContext.interaction, + await parse( + commands, + _nearestCommandContext, + StringView(rawContext.selected.single, isRestBlock: true), + RuntimeType(), + ), + ); + + context._parent = this; + _delegate = context; + + return context; + } + + @override + Future>> awaitMultiSelection( + ComponentId componentId, { + Converter? converterOverride, + }) async { + if (delegate != null) { + return delegate!.awaitMultiSelection( + componentId, + converterOverride: converterOverride, + ); + } + + SelectMenuContext> rawContext = + await commands.eventManager.nextSelectMenuEvent(componentId); + + SelectMenuContext> context = await commands.contextManager.createSelectMenuContext( + rawContext.interaction, + await Future.wait(rawContext.selected.map( + (value) => parse( + commands, + rawContext, + StringView(value, isRestBlock: true), + RuntimeType(), + ), + )), + ); + + context._parent = this; + _delegate = context; + + return context; + } + + @override + Future getButtonPress(Message message) async { + if (_delegate != null) { + return _delegate!.getButtonPress(message); + } + + final componentIds = message.components + ?.expand( + (component) => component is ActionRowComponent ? component.components : [component]) + .whereType() + .where((element) => element.customId != null) + .map((component) => ComponentId.parse(component.customId!)) + .toList() ?? + []; + + if (componentIds.any((element) => element == null)) { + throw UncaughtCommandsException( + 'Buttons in getButtonPress must have an ID set with ComponentId.generate()', + _nearestCommandContext, + ); + } + + int remaining = componentIds.length; + Completer completer = Completer(); + + for (final id in componentIds) { + commands.eventManager.nextButtonEvent(id!).then((context) { + if (completer.isCompleted) { + return; + } + + completer.complete(context); + }).catchError((Object error, StackTrace stackTrace) { + remaining--; + + if (remaining == 0 && !completer.isCompleted) { + // All the futures failed with an exception, throw the latest one back to the user + if (error is TimeoutException) { + error = InteractionTimeoutException( + 'Timed out waiting for button press on message ${message.id}', + _nearestCommandContext, + ); + } + + completer.completeError(error, stackTrace); + } + }); + } + + ButtonComponentContext context = await completer.future; + + context._parent = this; + _delegate = context; + + return context; + } + + @override + Future getButtonSelection( + List values, + MessageBuilder builder, { + Map? styles, + bool authorOnly = true, + ResponseLevel? level, + Duration? timeout, + FutureOr Function(T)? toButton, + Converter? converterOverride, + }) async { + if (_delegate != null) { + return _delegate!.getButtonSelection( + values, + builder, + authorOnly: authorOnly, + converterOverride: converterOverride, + level: level, + styles: styles, + timeout: timeout, + toButton: toButton, + ); + } + + assert( + toButton == null || converterOverride == null, + 'Cannot specify both toButton and converterOverride.', + ); + + toButton ??= converterOverride?.toButton; + toButton ??= commands.getConverter(RuntimeType())?.toButton; + + if (toButton == null) { + throw UncaughtCommandsException( + 'No suitable method found for converting $T to ButtonBuilder.', + _nearestCommandContext, + ); + } + + Map idToValue = {}; + + List buttons = await Future.wait(values.map((value) async { + ButtonBuilder builder = await toButton!(value); + ButtonStyle? style = styles?[value]; + + ComponentId id = ComponentId.generate( + expirationTime: timeout, + allowedUser: authorOnly ? user.id : null, + ); + + idToValue[id] = value; + + // We have to copy since the fields on ButtonBuilder are final. + return ButtonBuilder( + style: style ?? builder.style, + label: builder.label, + emoji: builder.emoji, + customId: id.toString(), + isDisabled: builder.isDisabled, + ); + })); + + final activeComponentRows = [...?builder.components]; + final disabledComponentRows = [...?builder.components]; + + while (buttons.isNotEmpty) { + // Max 5 buttons per row + int count = min(5, buttons.length); + + ActionRowBuilder activeRow = ActionRowBuilder(components: []); + ActionRowBuilder disabledRow = ActionRowBuilder(components: []); + + for (final button in buttons.take(count)) { + activeRow.components.add(button); + + disabledRow.components.add( + ButtonBuilder( + style: button.style, + label: button.label, + emoji: button.emoji, + customId: button.customId, + isDisabled: true, + ), + ); + } + + activeComponentRows.add(activeRow); + disabledComponentRows.add(disabledRow); + + buttons.removeRange(0, count); + } + + builder.components = activeComponentRows; + final message = await respond(builder, level: level); + + final listeners = + idToValue.keys.map((id) => commands.eventManager.nextButtonEvent(id)).toList(); + + try { + ButtonComponentContext context = await Future.any(listeners); + + context._parent = this; + _delegate = context; + + return idToValue[context.parsedComponentId]!; + } on TimeoutException catch (e, s) { + throw InteractionTimeoutException( + 'Timed out waiting for button selection', + _nearestCommandContext, + )..stackTrace = s; + } finally { + for (final id in idToValue.keys) { + commands.eventManager.stopListeningFor(id); + } + + await message.update( + MessageUpdateBuilder(components: disabledComponentRows), + ); + } + } + + @override + Future getConfirmation( + MessageBuilder builder, { + Map values = const {true: 'Yes', false: 'No'}, + Map styles = const {true: ButtonStyle.success, false: ButtonStyle.danger}, + bool authorOnly = true, + ResponseLevel? level, + Duration? timeout, + }) => + getButtonSelection( + [true, false], + builder, + toButton: (value) => ButtonBuilder( + style: ButtonStyle.primary, + label: values[value] ?? (value ? 'Yes' : 'No'), + customId: '', + ), + styles: styles, + authorOnly: authorOnly, + level: level, + timeout: timeout, + ); + + @override + Future getSelection( + List choices, + MessageBuilder builder, { + ResponseLevel? level, + Duration? timeout, + bool authorOnly = true, + FutureOr Function(T)? toSelectMenuOption, + Converter? converterOverride, + }) async { + if (_delegate != null) { + return _delegate!.getSelection( + choices, + builder, + authorOnly: authorOnly, + converterOverride: converterOverride, + level: level, + timeout: timeout, + toSelectMenuOption: toSelectMenuOption, + ); + } + + assert( + toSelectMenuOption == null || converterOverride == null, + 'Cannot specify both toSelectMenuOption and converterOverride', + ); + + toSelectMenuOption ??= converterOverride?.toSelectMenuOption; + toSelectMenuOption ??= commands.getConverter(RuntimeType())?.toSelectMenuOption; + + if (toSelectMenuOption == null) { + throw UncaughtCommandsException( + 'No suitable method for converting $T to SelectMenuOptionBuilder found', + _nearestCommandContext, + ); + } + + Map idToValue = {}; + List options = await Future.wait(choices.map( + (value) async { + SelectMenuOptionBuilder builder = await toSelectMenuOption!(value); + idToValue[builder.value] = value; + return builder; + }, + )); + + SelectMenuOptionBuilder prevPageOption = SelectMenuOptionBuilder( + label: 'Previous page', + value: ComponentId.generate().toString(), + ); + + SelectMenuOptionBuilder nextPageOption = SelectMenuOptionBuilder( + label: 'Next page', + value: ComponentId.generate().toString(), ); + + SelectMenuContext>? context; + int currentOffset = 0; + + SelectMenuBuilder? menu; + Message? message; + + try { + do { + bool hasPreviousPage = currentOffset != 0; + int itemsPerPage = hasPreviousPage ? 24 : 25; + bool hasNextPage = currentOffset + itemsPerPage < options.length; + + if (hasNextPage) { + itemsPerPage -= 1; + } + + final menuId = ComponentId.generate( + expirationTime: timeout, + allowedUser: authorOnly ? user.id : null, + ); + + menu = SelectMenuBuilder( + type: MessageComponentType.stringSelect, + customId: menuId.toString(), + options: [ + if (hasPreviousPage) prevPageOption, + ...options.skip(currentOffset).take(itemsPerPage), + if (hasNextPage) nextPageOption, + ], + ); + + ActionRowBuilder row = ActionRowBuilder(components: [menu]); + if (context == null) { + // This is the first time we're sending a message, just append the component row. + (builder.components ??= []).add(row); + + message = await respond(builder, level: level); + } else { + // On later iterations, replace the last row with our newly created one. + List rows = builder.components!; + + rows[rows.length - 1] = row; + + await context.respond( + builder, + level: (level ?? _nearestCommandContext.command.resolvedOptions.defaultResponseLevel)! + .copyWith(preserveComponentMessages: false), + ); + } + + context = await commands.eventManager.nextSelectMenuEvent(menuId); + + if (context.selected.single == nextPageOption.value) { + currentOffset += itemsPerPage; + } else if (context.selected.single == prevPageOption.value) { + currentOffset -= itemsPerPage; + } + } while (context.selected.single == nextPageOption.value || + context.selected.single == prevPageOption.value); + + context._parent = this; + _delegate = context; + + final result = idToValue[context.selected.single] as T; + + final matchingOptionIndex = menu.options!.indexWhere( + (option) => option.value == context!.selected.single, + ); + + if (matchingOptionIndex >= 0) { + menu.options![matchingOptionIndex].isDefault = true; + } + + return result; + } on TimeoutException catch (e, s) { + throw InteractionTimeoutException( + 'Timed out waiting for selection', + _nearestCommandContext, + )..stackTrace = s; + } finally { + if (menu != null && message != null) { + menu.isDisabled = true; + await message.edit(MessageCreateUpdateBuilder.fromMessageBuilder(builder)); + } + } + } + + @override + Future> getMultiSelection( + List choices, + MessageBuilder builder, { + ResponseLevel? level, + Duration? timeout, + bool authorOnly = true, + FutureOr Function(T)? toSelectMenuOption, + Converter? converterOverride, + }) async { + if (_delegate != null) { + return _delegate!.getMultiSelection( + choices, + builder, + authorOnly: authorOnly, + converterOverride: converterOverride, + level: level, + timeout: timeout, + toSelectMenuOption: toSelectMenuOption, + ); + } + + toSelectMenuOption ??= converterOverride?.toSelectMenuOption; + toSelectMenuOption ??= commands.getConverter(RuntimeType())?.toSelectMenuOption; + + if (toSelectMenuOption == null) { + throw UncaughtCommandsException( + 'No suitable method for converting $T to SelectMenuOptionBuilder found', + _nearestCommandContext, + ); + } + + Map idToValue = {}; + List options = await Future.wait(choices.map( + (value) async { + SelectMenuOptionBuilder builder = await toSelectMenuOption!(value); + idToValue[builder.value] = value; + return builder; + }, + )); + + ComponentId menuId = ComponentId.generate( + expirationTime: timeout, + allowedUser: authorOnly ? user.id : null, + ); + + SelectMenuBuilder menu = SelectMenuBuilder( + type: MessageComponentType.stringSelect, + customId: menuId.toString(), + options: options, + minValues: choices.length, + ); + ActionRowBuilder row = ActionRowBuilder(components: [menu]); + + (builder.components ??= []).add(row); + + Message message = await respond(builder, level: level); + + try { + SelectMenuContext> context = + await commands.eventManager.nextSelectMenuEvent(menuId); + + context._parent = this; + _delegate = context; + + for (final value in context.selected) { + final matchingOptionIndex = menu.options!.indexWhere((option) => option.value == value); + + if (matchingOptionIndex >= 0) { + menu.options![matchingOptionIndex] = SelectMenuOptionBuilder( + label: menu.options![matchingOptionIndex].label, + value: value, + ); + } + } + + return context.selected.map((id) => idToValue[id]!).toList(); + } on TimeoutException catch (e, s) { + throw InteractionTimeoutException( + 'TImed out waiting for selection', + _nearestCommandContext, + )..stackTrace = s; + } finally { + menu.isDisabled = true; + await message.edit(MessageCreateUpdateBuilder.fromMessageBuilder(builder)); + } + } +} + +mixin InteractionRespondMixin + implements InteractionInteractiveContext, InteractionContextData, InteractiveMixin { + @override + MessageResponse get interaction; + + ResponseLevel? _responseLevel; + bool _hasResponded = false; + + Future? _acknowledgeLock; + + @override + Future respond(MessageBuilder builder, {ResponseLevel? level}) async { + await _acknowledgeLock; + + if (_delegate != null) { + return _delegate!.respond(builder, level: level); + } + + level ??= _nearestCommandContext.command.resolvedOptions.defaultResponseLevel!; + + if (_hasResponded) { + // We've already responded, just send a followup. + return interaction.createFollowup(builder, isEphemeral: level.hideInteraction); + } + + _hasResponded = true; + + if (_responseLevel != null && _responseLevel!.hideInteraction != level.hideInteraction) { + // We acknowledged the interaction but our original acknowledgement doesn't correspond to + // what's being requested here. + // It's a bit ugly, but send an empty response and delete it to match [level]. + + await interaction.respond(MessageBuilder(content: '‎')); + await interaction.deleteOriginalResponse(); + + return interaction.createFollowup(builder, isEphemeral: level.hideInteraction); + } + + // If we want to preserve the original message a component is attached to, we can just send a + // followup instead of a response. + // Also, if we are requested to hide interactions, also send a followup, or + // components will just edit the original message (making it public). + if (level.hideInteraction) { + await interaction.respond(builder, isEphemeral: level.hideInteraction); + return interaction.fetchOriginalResponse(); + } + + if (interaction is MessageComponentInteraction) { + // Using interactionEvent.respond is actually the same as editing a message in the case where + // the interaction is a message component. In those cases, leaving `componentRows` as `null` + // would leave the existing components on the message - which likely isn't what our users + // expect. Instead, we override them and set the builder to have no components. + builder.components ??= []; + + await (interaction as MessageComponentInteraction) + .respond(builder, updateMessage: !level.preserveComponentMessages); + return interaction.fetchOriginalResponse(); + } + + await interaction.respond(builder, isEphemeral: level.hideInteraction); + return interaction.fetchOriginalResponse(); + } + + @override + Future acknowledge({ResponseLevel? level}) async { + await _acknowledgeLock; + + final lockCompleter = Completer(); + _acknowledgeLock = lockCompleter.future; + + try { + _responseLevel = + level ??= _nearestCommandContext.command.resolvedOptions.defaultResponseLevel!; + if (interaction is MessageComponentInteraction) { + await (interaction as MessageComponentInteraction).acknowledge( + isEphemeral: level.hideInteraction, + updateMessage: !level.preserveComponentMessages, + ); + } else { + await interaction.acknowledge(isEphemeral: level.hideInteraction); + } + } finally { + lockCompleter.complete(); + _acknowledgeLock = null; + } + } + + @override + Future awaitModal(String customId, {Duration? timeout}) async { + if (_delegate != null) { + if (_delegate is! InteractionInteractiveContext) { + throw UncaughtCommandsException( + "Couldn't delegate awaitModal() to non-interaction context", + _nearestCommandContext, + ); + } + + return (_delegate as InteractionInteractiveContext).awaitModal(customId, timeout: timeout); + } + + Future event = client.onModalSubmitInteraction + .map((e) => e.interaction) + .where( + (event) => event.data.customId == customId, + ) + .first; + + if (timeout != null) { + event = event.timeout(timeout); + } + + ModalContext context = await commands.contextManager.createModalContext(await event); + + context._parent = this; + _delegate = context; + + return context; + } + + @override + Future getModal({ + required String title, + required List components, + Duration? timeout, + }) async { + if (_delegate != null) { + if (_delegate is! InteractionInteractiveContext) { + throw UncaughtCommandsException( + "Couldn't delegate getModal() to non-interaction context", + _nearestCommandContext, + ); + } + + return (_delegate as InteractionInteractiveContext).getModal( + title: title, + components: components, + timeout: timeout, + ); + } + + final interaction = this.interaction; + if (interaction is! ModalResponse) { + throw UncaughtCommandsException( + 'Cannot respond to a context of type $runtimeType with a modal', + _nearestCommandContext, + ); + } + + ModalBuilder builder = ModalBuilder( + customId: ComponentId.generate().toString(), + title: title, + components: components.map((textInput) => ActionRowBuilder(components: [textInput])).toList(), + ); + + await (interaction as ModalResponse).respondModal(builder); + + return awaitModal(builder.customId, timeout: timeout); + } +} + +mixin MessageRespondMixin implements InteractiveMixin { + Message get message; + + @override + Future respond(MessageBuilder builder, {ResponseLevel? level}) async { + if (_delegate != null) { + return _delegate!.respond(builder, level: level); + } + + level ??= _nearestCommandContext.command.resolvedOptions.defaultResponseLevel!; + + if (level.isDm) { + final dmChannel = await client.users.createDm(user.id); + return dmChannel.sendMessage(builder); + } + + if (builder.replyId == null) { + builder.replyId = message.id; + + if (level.mention case final shouldMention?) { + final allowedMentions = builder.allowedMentions ?? AllowedMentions(); + final replyMentions = AllowedMentions(repliedUser: shouldMention); + builder.allowedMentions = + shouldMention ? allowedMentions | replyMentions : allowedMentions & replyMentions; + } + } + + return channel.sendMessage(builder); } } diff --git a/lib/src/util/util.dart b/lib/src/util/util.dart index 0596435..228b76d 100644 --- a/lib/src/util/util.dart +++ b/lib/src/util/util.dart @@ -1,22 +1,9 @@ -// 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 'dart:async'; import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; +import '../commands/chat_command.dart'; +import '../commands/interfaces.dart'; import '../context/autocomplete_context.dart'; import '../converters/converter.dart'; import 'view.dart'; @@ -139,7 +126,7 @@ class Choices { /// The values can be either [String]s or [int]s. /// /// You might also be interested in: - /// - [ArgChoiceBuilder], the nyxx_interactions builder these entries are converted to. + /// - [CommandOptionChoiceBuilder], the nyxx_interactions builder these entries are converted to. final Map choices; /// Create a new [Choices]. @@ -149,8 +136,8 @@ class Choices { const Choices(this.choices); /// Get the builders that this [Choices] represents. - Iterable get builders => - choices.entries.map((entry) => ArgChoiceBuilder(entry.key, entry.value)); + Iterable> get builders => choices.entries + .map((entry) => CommandOptionChoiceBuilder(name: entry.key, value: entry.value)); @override String toString() => 'Choices[choices=$choices]'; @@ -243,11 +230,12 @@ class UseConverter { /// ``` /// /// You might also be interested in: -/// - [Converter.autoCompleteCallback], the way to register autocomplete handlers for all arguments +/// - [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; + final FutureOr>?> Function(AutocompleteContext) + callback; /// Create a new [Autocomplete]. /// @@ -270,17 +258,20 @@ final RegExp _mentionPattern = RegExp(r'^<@!?([0-9]{15,20})>'); /// ``` /// /// ![](https://user-images.githubusercontent.com/54505189/156937410-73d19cc5-c018-40e4-97dd-b7fcc0be0b7d.png) -String Function(IMessage) mentionOr(String Function(IMessage) defaultPrefix) { - return (message) { - RegExpMatch? match = _mentionPattern.firstMatch(message.content); - - if (match != null && message.client is INyxxWebsocket) { - if (int.parse(match.group(1)!) == (message.client as INyxxWebsocket).self.id.id) { +Future Function(MessageCreateEvent) mentionOr( + FutureOr Function(MessageCreateEvent) defaultPrefix, +) { + return (event) async { + RegExpMatch? match = _mentionPattern.firstMatch(event.message.content); + + if (match != null) { + if (int.parse(match.group(1)!) == + (await event.gateway.client.users.fetchCurrentUser()).id.value) { return match.group(0)!; } } - return defaultPrefix(message); + return defaultPrefix(event); }; } @@ -297,11 +288,12 @@ String Function(IMessage) mentionOr(String Function(IMessage) defaultPrefix) { /// ``` /// ![](https://user-images.githubusercontent.com/54505189/156937528-df54a2ba-627d-4f54-b0bc-ad7cb6321965.png) /// ![](https://user-images.githubusercontent.com/54505189/156937561-9df9e6cf-6595-465d-895a-aaca5d6ff066.png) -String Function(IMessage) dmOr(String Function(IMessage) defaultPrefix) { - return (message) { - String found = defaultPrefix(message); +Future Function(MessageCreateEvent) dmOr( + FutureOr Function(MessageCreateEvent) defaultPrefix) { + return (event) async { + String found = await defaultPrefix(event); - if (message.guild != null || StringView(message.content).skipString(found)) { + if (event.guild != null || StringView(event.message.content).skipString(found)) { return found; } @@ -311,7 +303,7 @@ String Function(IMessage) dmOr(String Function(IMessage) defaultPrefix) { /// A pattern all command and argument names should match. /// -/// For more inforrmation on naming restrictions, check the +/// For more information 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'^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$', @@ -321,7 +313,7 @@ final RegExp commandNameRegexp = RegExp( final Map idMap = {}; /// A special function that can be wrapped around another function in order to tell nyxx_commands -/// how to identify the funcion at compile time. +/// how to identify the function at compile time. /// /// This function is used to identify a callback function so that compiled nyxx_commands can extract /// the type & annotation data for that function. @@ -334,3 +326,326 @@ T id(dynamic id, T fn) { return fn; } + +ChatCommand? getCommandHelper(StringView view, Map children) { + String name = view.getWord(); + String lowerCaseName = name.toLowerCase(); + + try { + ChatCommandComponent child = children.entries.singleWhere((childEntry) { + bool isCaseInsensitive = childEntry.value.resolvedOptions.caseInsensitiveCommands!; + + if (isCaseInsensitive) { + return lowerCaseName == childEntry.key.toLowerCase(); + } + + return name == childEntry.key; + }).value; + + ChatCommand? commandFromChild = child.getCommand(view); + + // If no command further down the tree was found, return the child if it is a chat command + // that can be invoked from a text message (not slash only). + if (commandFromChild == null && + child is ChatCommand && + child.resolvedOptions.type != CommandType.slashOnly) { + return child; + } + + return commandFromChild; + } on StateError { + // Don't consume any input if no command was found. + view.undo(); + return null; + } +} + +/// An identifier for message components containing metadata about the handler associated with the +/// component. +/// +/// This class contains the data needed for nyxx_commands to find the correct handler for a +/// component interaction event, and throw an error if no handler is found. +/// +/// Call [toString] to get the custom id to use on a component. A new [ComponentId] shhould be used +/// for each component. +/// +/// [ComponentId]s should not be stored before use. See [expiresAt] for the reason why. +class ComponentId { + /// A unique identifier (in this process) for this component. + /// + /// Every [ComponentId] will get a new [uniqueIdentifier]. + final int uniqueIdentifier; + + /// The time at which the process that created this [ComponentId] was started. + /// + /// This will be the same for all [ComponentId]s created in the same process and allows + /// nyxx_commands to tell when an interaction comes from a previous session, meaning no handler + /// will be found. + final DateTime sessionStartTime; + + /// If the handler associated with this component has an expiration timeout, the time at which it + /// will expire, otherwise null. + /// + /// This is set as soon as this [ComponentId] is created, so [ComponentId]s should be used as soon + /// as they are created. + final DateTime? expiresAt; + + /// If the handler associated with this component only allows a specific user to use the + /// component, the ID of that user, otherwise null. + final Snowflake? allowedUser; + + /// The time remaining until the handler for this [ComponentId] expires, if [expiresAt] was set. + Duration? get expiresIn => expiresAt?.difference(DateTime.now()); + + /// The status of this [ComponentId]. + /// + /// This will always be [ComponentIdStatus.ok] for [ComponentId]s created using + /// [ComponentId.generate] but will contain information about the status of the handler if this + /// [ComponentId] was received from the API and created using [ComponentId.parse]. + final ComponentIdStatus status; + + /// The start time of the current session. + /// + /// This will be the value of [ComponentId.sessionStartTime] for all [ComponentId]s created in + /// this process. + static final currentSessionStartTime = DateTime.now().toUtc(); + + static int _uniqueIdentifier = 0; + + /// Create a new [ComponentId]. + const ComponentId({ + required this.uniqueIdentifier, + required this.sessionStartTime, + required this.expiresAt, + required this.status, + required this.allowedUser, + }); + + /// Generate a new unique [ComponentId]. + /// + /// [expirationTime] should be the time after which the handler will expire. [allowedUser] should + /// be the ID of the user allows to interact with this component. + factory ComponentId.generate({Duration? expirationTime, Snowflake? allowedUser}) => ComponentId( + uniqueIdentifier: _uniqueIdentifier++, + sessionStartTime: currentSessionStartTime, + expiresAt: expirationTime != null ? DateTime.now().add(expirationTime).toUtc() : null, + status: ComponentIdStatus.ok, + allowedUser: allowedUser, + ); + + /// Parse a [ComponentId] received from the API. + /// + /// This method parses the string returned by a call to [toString]. + /// + /// If [id] was not a [ComponentId] created by nyxx_commands, such as a manually set custom id, + /// this method will return `null`. + static ComponentId? parse(String id) { + final parts = id.split('/'); + + if (parts.isEmpty || parts.first != 'nyxx_commands') { + return null; + } + + final uniqueIdentifier = int.parse(parts[1]); + final sessionStartTime = DateTime.parse(parts[2]); + final expiresAt = parts[3] != 'null' ? DateTime.parse(parts[3]) : null; + final allowedUser = parts[4] != 'null' ? Snowflake.parse(parts[4]) : null; + + final ComponentIdStatus? status; + if (sessionStartTime != currentSessionStartTime) { + status = ComponentIdStatus.fromDifferentSession; + } else if (expiresAt?.isBefore(DateTime.now()) ?? false) { + status = ComponentIdStatus.expired; + } else { + status = ComponentIdStatus.ok; + } + + return ComponentId( + expiresAt: expiresAt, + sessionStartTime: sessionStartTime, + status: status, + uniqueIdentifier: uniqueIdentifier, + allowedUser: allowedUser, + ); + } + + /// Copy this [ComponentId] with a new status. + ComponentId withStatus(ComponentIdStatus status) => ComponentId( + expiresAt: expiresAt, + sessionStartTime: sessionStartTime, + status: status, + uniqueIdentifier: uniqueIdentifier, + allowedUser: allowedUser, + ); + + @override + // When adding new fields, ensure we don't go over the maximum length (100). + // Current length: + // 13 - nyxx_commands prefix + // 4 - / separators + // 6 - uniqueIdentifier (assume we won't go over 1 000 000 interactions in one session) + // 27 - sessionStartTime + // 27 - expiresAt + // 19 - allowedUser + // Total: 96, 4 free (could be used up by uniqueIdentifier) + // TODO: Serialize to binary => encode base64? + String toString() => 'nyxx_commands/$uniqueIdentifier/$sessionStartTime/$expiresAt/$allowedUser'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ComponentId && + other.uniqueIdentifier == uniqueIdentifier && + other.sessionStartTime == sessionStartTime && + other.expiresAt == expiresAt && + other.allowedUser == allowedUser); + + @override + int get hashCode => Object.hash(uniqueIdentifier, sessionStartTime, expiresAt, allowedUser); +} + +/// The status of the handler associated with a [ComponentId]. +enum ComponentIdStatus { + /// No problems. + /// + /// This status shouldn't ever occur in an error. + ok, + + /// The [ComponentId] was created in a different process, so no handler could be found. + /// + /// This also means that the state associated with the handler is now lost. + fromDifferentSession, + + /// The handler for this [ComponentId] has expired. + expired, + + /// No handler for this [ComponentId] was found. + /// + /// This can happen when two instances of nyxx_commands are running at the same time, so the event + /// will probably be handled by the other instance. + noHandlerFound, + + /// The user who interacted with the component was not allowed to do so. + wrongUser; + + @override + String toString() { + switch (this) { + case ComponentIdStatus.ok: + return 'OK'; + case ComponentIdStatus.fromDifferentSession: + return 'From different session'; + case ComponentIdStatus.expired: + return 'Expired'; + case ComponentIdStatus.noHandlerFound: + return 'No handler found'; + case ComponentIdStatus.wrongUser: + return 'User not allowed'; + } + } +} + +class MessageCreateUpdateBuilder extends MessageBuilder implements MessageUpdateBuilder { + MessageCreateUpdateBuilder({ + super.content, + super.nonce, + super.tts, + super.embeds, + super.allowedMentions, + super.replyId, + super.requireReplyToExist, + super.components, + super.stickerIds, + super.attachments, + super.suppressEmbeds, + super.suppressNotifications, + }); + + MessageCreateUpdateBuilder.fromMessageBuilder(MessageBuilder builder) + : this( + content: builder.content, + nonce: builder.nonce, + tts: builder.tts, + embeds: builder.embeds, + allowedMentions: builder.allowedMentions, + replyId: builder.replyId, + requireReplyToExist: builder.requireReplyToExist, + components: builder.components, + stickerIds: builder.stickerIds, + attachments: builder.attachments, + suppressEmbeds: builder.suppressEmbeds, + suppressNotifications: builder.suppressNotifications, + ); +} + +/// Adapted from https://discord.com/developers/docs/topics/permissions +Future computePermissions( + Guild guild, + GuildChannel channel, + Member member, +) async { + Future computeBasePermissions() async { + if (guild.ownerId == member.id) { + return Permissions.allPermissions; + } + + final everyoneRole = await guild.roles[guild.id].get(); + Flags permissions = everyoneRole.permissions; + + for (final role in member.roles) { + final rolePermissions = (await role.get()).permissions; + + permissions |= rolePermissions; + } + + permissions = Permissions(permissions.value); + permissions as Permissions; + + if (permissions.isAdministrator) { + return Permissions.allPermissions; + } + + return permissions; + } + + Future computeOverwrites(Permissions basePermissions) async { + if (basePermissions.isAdministrator) { + return Permissions.allPermissions; + } + + Flags permissions = basePermissions; + + final everyoneOverwrite = + channel.permissionOverwrites.where((overwrite) => overwrite.id == guild.id).singleOrNull; + if (everyoneOverwrite != null) { + permissions &= ~everyoneOverwrite.deny; + permissions |= everyoneOverwrite.allow; + } + + Flags allow = Permissions(0); + Flags deny = Permissions(0); + + for (final roleId in member.roleIds) { + final roleOverwrite = + channel.permissionOverwrites.where((overwrite) => overwrite.id == roleId).singleOrNull; + if (roleOverwrite != null) { + allow |= roleOverwrite.allow; + deny |= roleOverwrite.deny; + } + } + + permissions &= ~deny; + permissions |= allow; + + final memberOverwrite = + channel.permissionOverwrites.where((overwrite) => overwrite.id == member.id).singleOrNull; + if (memberOverwrite != null) { + permissions &= ~memberOverwrite.deny; + permissions |= memberOverwrite.allow; + } + + return Permissions(permissions.value); + } + + return computeOverwrites(await computeBasePermissions()); +} diff --git a/lib/src/util/view.dart b/lib/src/util/view.dart index 6c6c8ea..1ad6e66 100644 --- a/lib/src/util/view.dart +++ b/lib/src/util/view.dart @@ -1,17 +1,3 @@ -// 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 '../errors.dart'; const Map _quotes = { @@ -110,6 +96,7 @@ class StringView { /// /// You might also be interested in: /// - [skipWhitespace], for skipping arbitrary spans of whitespace. + /// - [skipPattern], for testing arbitrary patterns. bool skipString(String s) { if (index + s.length < end && buffer.substring(index, index + s.length) == s) { history.add(index); @@ -119,6 +106,24 @@ class StringView { return false; } + /// Match [p] at the text directly after the cursor and skip over the match if it exists, else + /// return `null`. + /// + /// You might also be interested in: + /// - [skipWhitespace], for skipping arbitrary spans of whitespace. + /// - [skipString], for skipping arbitrary strings. + Match? skipPattern(Pattern p) { + Match? match = p.matchAsPrefix(buffer.substring(index)); + + if (match != null) { + history.add(index); + // The end of the match is the same as its length since it was matched as a prefix. + index += match.end; + } + + return match; + } + /// Skip to the next non-whitespace character in [buffer]. /// /// In this case, *whitespace* refers to a non-escaped space character (ASCII 32). @@ -150,7 +155,7 @@ class StringView { /// Consume and return the next word in [buffer], disregarding quotes. /// - /// Developers should use [getQuotedWord] instead unless they specifically want the behaviour + /// Developers should use [getQuotedWord] instead unless they specifically want the behavior /// described below, as [getWord] can leave [remaining] with unbalanced quotes. /// /// A *word* is a sequence of non-whitespace characters, themselves surrounded by whitespace. The @@ -160,7 +165,7 @@ class StringView { /// The word is escaped before it is returned. /// /// You might also be interested in: - /// - [escape], for escaping arbitrary postions of [buffer]; + /// - [escape], for escaping arbitrary portions of [buffer]; /// - [isWhitespace], for checking if the current character is considered whitespace. String getWord() { skipWhitespace(); @@ -176,9 +181,9 @@ class StringView { /// Consume and return the next word or quoted portion in [buffer]. /// - /// See [getWord] for a description of wwhat is considered a *word*. + /// See [getWord] for a description of what is considered a *word*. /// - /// In addition to the behaviour of [getWord], [getQuotedWord] will return the portion of [buffer] + /// In addition to the behavior of [getWord], [getQuotedWord] will return the portion of [buffer] /// between an opening quote and a corresponding, non-escaped closing quote if the next word /// begins with a quote. The quotes are consumed but not returned. /// diff --git a/pubspec.yaml b/pubspec.yaml index 5a00ae0..1bf4023 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_commands -version: 4.4.1 +version: 6.0.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 @@ -7,23 +7,22 @@ repository: https://github.com/nyxx-discord/nyxx_commands issue_tracker: https://github.com/nyxx-discord/nyxx_commands/issues environment: - sdk: '>=2.17.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: analyzer: ^5.7.1 args: ^2.4.0 dart_style: ^2.2.5 logging: ^1.1.1 - meta: ^1.9.0 - nyxx: ^4.5.0 - nyxx_interactions: ^4.5.0 - random_string: ^2.3.1 + nyxx: ^6.0.0 path: ^1.8.3 + runtime_type: ^1.0.1 + fuzzywuzzy: ^1.1.6 dev_dependencies: build_runner: ^2.1.0 coverage: ^1.6.3 - lints: ^2.0.1 + lints: ^3.0.0 mockito: ^5.3.2 test: ^1.23.1 diff --git a/test/unit/event_manager.dart b/test/unit/event_manager.dart new file mode 100644 index 0000000..dc2d829 --- /dev/null +++ b/test/unit/event_manager.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:mockito/mockito.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_commands/nyxx_commands.dart'; + +import 'package:test/test.dart'; + +void main() { + group('EventManager with component events', () { + test('completes with the correct event', () { + final eventManager = EventManager(MockCommandsPlugin()); + + final componentId = ComponentId.generate(); + final nextEventFuture = eventManager.nextButtonEvent(componentId); + + eventManager.processButtonInteraction(MockButtonInteractionEvent( + componentId.toString(), + Snowflake.zero, + )); + + expect(nextEventFuture, completes); + }); + + test('times out if a timeout is specified', () { + final eventManager = EventManager(MockCommandsPlugin()); + + final componentId = ComponentId.generate( + expirationTime: Duration(seconds: 1), + ); + + final nextEventFuture = eventManager.nextButtonEvent(componentId); + + expect(nextEventFuture, throwsA(isA())); + }); + + test('throws if an unknown user runs the interaction', () { + final eventManager = EventManager(MockCommandsPlugin()); + + final componentId = ComponentId.generate( + allowedUser: Snowflake.zero, + ); + + expect( + () => eventManager.processButtonInteraction(MockButtonInteractionEvent( + componentId.toString(), + Snowflake(1), + )), + throwsA( + isA().having( + (exception) => exception.reason, + 'reason is wrongUser', + equals(ComponentIdStatus.wrongUser), + ), + ), + ); + }); + + test('throws if no handler was found', () { + final eventManager = EventManager(MockCommandsPlugin()); + + final componentId = ComponentId.generate(); + + expect( + () => eventManager.processButtonInteraction(MockButtonInteractionEvent( + componentId.toString(), + Snowflake.zero, + )), + throwsA( + isA().having( + (exception) => exception.reason, + 'reason is noHandlerFound', + equals(ComponentIdStatus.noHandlerFound), + ), + ), + ); + }); + }); +} + +class MockCommandsPlugin with Mock implements CommandsPlugin { + @override + ContextManager get contextManager => ContextManager(this); +} + +class MockButtonInteractionEvent with Mock implements MessageComponentInteraction { + MockButtonInteractionEvent(String customId, Snowflake userId) + : data = MockMessageComponentInteractionData(customId), + user = MockUser(userId); + + @override + final MessageComponentInteractionData data; + + @override + PartialChannel get channel => MockChannel(); + + @override + final User? user; + + @override + InteractionManager get manager => MockInteractionManager(); +} + +class MockMessageComponentInteractionData with Mock implements MessageComponentInteractionData { + @override + final String customId; + + MockMessageComponentInteractionData(this.customId); +} + +class MockUser with Mock implements User { + @override + final Snowflake id; + + MockUser(this.id); +} + +class MockNyxx with Mock implements NyxxGateway {} + +class MockChannel with Mock implements TextChannel { + @override + Future get() async => this; +} + +class MockInteractionManager with Mock implements InteractionManager { + @override + NyxxRest get client => MockNyxx(); +}