From bcd12ee369da1aed1d5fc1291ca8d26345263262 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sun, 28 Feb 2021 11:05:56 +0100 Subject: [PATCH] Fixes for slash commands; Implemented Commander like interface for interactions --- nyxx.interactions/lib/src/Interactions.dart | 48 ++++++++++++++--- .../lib/src/events/InteractionEvent.dart | 8 +-- .../lib/src/models/Interaction.dart | 51 ++++++++----------- .../lib/src/models/InteractionOption.dart | 29 ++++++++--- .../lib/src/models/SlashCommand.dart | 34 ++++++++++--- .../lib/src/models/commandArgs/ArgChoice.dart | 10 ++-- .../src/models/commandArgs/CommandArg.dart | 43 +++++++--------- nyxx.interactions/pubspec.yaml | 4 ++ nyxx/lib/src/Nyxx.dart | 2 +- nyxx/lib/src/internal/_HttpEndpoints.dart | 10 +--- nyxx/lib/src/internal/http/HttpResponse.dart | 5 +- 11 files changed, 147 insertions(+), 97 deletions(-) diff --git a/nyxx.interactions/lib/src/Interactions.dart b/nyxx.interactions/lib/src/Interactions.dart index 5958d98d..314cf6b3 100644 --- a/nyxx.interactions/lib/src/Interactions.dart +++ b/nyxx.interactions/lib/src/Interactions.dart @@ -1,7 +1,12 @@ part of nyxx_interactions; +typedef SlashCommandHandlder = FutureOr Function(InteractionEvent); + /// Interaction extension for Nyxx. Allows use of: Slash Commands. class Interactions { + static const _interactionCreateCommand = "INTERACTION_CREATE"; + static const _op0 = 0; + late final Nyxx _client; final Logger _logger = Logger("Interactions"); final List _commands = []; @@ -13,6 +18,8 @@ class Interactions { /// Emitted when a slash command is created by the user. late final Stream onSlashCommandCreated; + final _commandHandlers = {}; + /// Create new instance of the interactions class. Interactions(Nyxx client) { this._client = client; @@ -20,19 +27,43 @@ class Interactions { _client.options.dispatchRawShardEvent = true; _logger.info("Interactions ready"); - client.onReady.listen((event) { + client.onReady.listen((event) async { client.shardManager.rawEvent.listen((event) { - if (event.rawData["op"] as int == 0) { - if (event.rawData["t"] as String == "INTERACTION_CREATE") { + if (event.rawData["op"] as int == _op0) { + if (event.rawData["t"] as String == _interactionCreateCommand) { _events.onSlashCommand.add( InteractionEvent._new(client, event.rawData["d"] as Map), ); } } }); + + if (this._commandHandlers.isNotEmpty) { + await this.sync(); + this.onSlashCommand.listen((event) async { + try { + final handler = _commandHandlers[event.interaction.name]; + + if (handler == null) { + return; + } + + await handler(event); + } on Error catch (e) { + this._logger.severe("Failed to execute command (${event.interaction.name})", e); + } + }); + } }); } + /// Registers command and handler for that command. + void registerHandler(String name, String description, List args, {required SlashCommandHandlder handler, Snowflake? guild}) { + final command = this.createCommand(name, description, args, guild: guild); + this.registerCommand(command); + _commandHandlers[name] = handler; + } + /// Creates a command that can be registered using .registerCommand or .registerCommands /// /// The [name] is the name that the user can see when typing /, the [description] can also be seen in this same place. [args] are any arguments you want the user to type, you can put an empty list here is you require no arguments. If you want this to be specific to a guild you can set the [guild] param with the ID of a guild, when testing its recommended to use this as it propagates immediately while global commands can take some time. @@ -65,12 +96,15 @@ class Interactions { var failed = 0; for (final command in _commands) { if (!command.isRegistered) { - await command._register().then((value) { - this._events.onSlashCommandCreated.add(command); + try { + final registeredCommand = await command._register(); + this._events.onSlashCommandCreated.add(registeredCommand); + success++; - }).catchError(() { + } on HttpResponseError catch (e) { + this._logger.severe("Failed registering command: ${e.toString()}"); failed++; - }); + } } } _logger.info( diff --git a/nyxx.interactions/lib/src/events/InteractionEvent.dart b/nyxx.interactions/lib/src/events/InteractionEvent.dart index a0835c4d..38ababf8 100644 --- a/nyxx.interactions/lib/src/events/InteractionEvent.dart +++ b/nyxx.interactions/lib/src/events/InteractionEvent.dart @@ -10,9 +10,8 @@ class InteractionEvent { /// If the Client has sent a response to the Discord API. Once the API was received a response you cannot send another. bool hasResponded = false; - InteractionEvent._new(Nyxx client, Map rawJson) { - this._client = client; - this.interaction = Interaction._new(client, rawJson); + InteractionEvent._new(this._client, Map rawJson) { + this.interaction = Interaction._new(this._client, rawJson); if (this.interaction.type == 1) { this._pong(); @@ -73,7 +72,7 @@ class InteractionEvent { } /// Used to acknowledge a Interaction and send a response. Once this is sent you can then only send ChannelMessages. You can also set showSource to also print out the command the user entered. - Future reply({ dynamic content, EmbedBuilder? embed, bool? tts, AllowedMentions? allowedMentions, bool showSource = false, }) async { + Future reply({ dynamic content, EmbedBuilder? embed, bool? tts, AllowedMentions? allowedMentions, bool showSource = false, bool hidden = false}) async { if (DateTime.now().isBefore(this.receivedAt.add(const Duration(minutes: 15)))) { String url; if (hasResponded) { @@ -87,6 +86,7 @@ class InteractionEvent { body: { "type": showSource ? 4 : 3, "data": { + if (hidden) "flags": 1 << 6, "content": content, "embeds": embed != null ? [BuilderUtility.buildRawEmbed(embed)] : null, "allowed_mentions": diff --git a/nyxx.interactions/lib/src/models/Interaction.dart b/nyxx.interactions/lib/src/models/Interaction.dart index f289353c..f73d59df 100644 --- a/nyxx.interactions/lib/src/models/Interaction.dart +++ b/nyxx.interactions/lib/src/models/Interaction.dart @@ -1,7 +1,7 @@ part of nyxx_interactions; /// The Interaction data. e.g channel, guild and member -class Interaction extends SnowflakeEntity implements Disposable { +class Interaction extends SnowflakeEntity { /// Reference to bot instance. final Nyxx _client; @@ -27,54 +27,45 @@ class Interaction extends SnowflakeEntity implements Disposable { late final String name; /// Args of the interaction - late final Map args; + late final Iterable args; - Interaction._new( - this._client, - Map raw, - ) : super(Snowflake(raw["id"])) { + /// Id of command + late final Snowflake commandId; + + Interaction._new(this._client, Map raw) : super(Snowflake(raw["id"])) { this.type = raw["type"] as int; + this.guild = CacheUtility.createCacheableGuild( _client, - Snowflake( - raw["guild_id"], - ), + Snowflake(raw["guild_id"],), ); + this.channel = CacheUtility.createCacheableTextChannel( _client, - Snowflake( - raw["channel_id"], - ), + Snowflake(raw["channel_id"]), ); + this.author = EntityUtility.createGuildMember( _client, - Snowflake( - raw["guild_id"], - ), + Snowflake(raw["guild_id"]), raw["member"] as Map, ); + this.token = raw["token"] as String; this.version = raw["version"] as int; this.name = raw["data"]["name"] as String; this.args = _generateArgs(raw["data"] as Map); + this.commandId = Snowflake(raw["data"]["id"]); } - Map _generateArgs(Map rawData) { - final args = {}; - - if (rawData["options"] != null) { - final options = rawData["options"] as List; - for (final option in options) { - args[option["name"] as String] = InteractionOption._new( - option["value"] as dynamic, - (option["options"] ?? List.empty()) as List>, - ); - } + Iterable _generateArgs(Map rawData) sync* { + if (rawData["options"] == null) { + return; } - return args; + final options = rawData["options"] as List; + for (final option in options) { + yield InteractionOption._new(option as Map); + } } - - @override - Future dispose() => Future.value(null); } diff --git a/nyxx.interactions/lib/src/models/InteractionOption.dart b/nyxx.interactions/lib/src/models/InteractionOption.dart index afd75709..cf7be90e 100644 --- a/nyxx.interactions/lib/src/models/InteractionOption.dart +++ b/nyxx.interactions/lib/src/models/InteractionOption.dart @@ -3,17 +3,30 @@ part of nyxx_interactions; /// The option given by the user when sending a command class InteractionOption { /// The value given by the user - final dynamic? value; + late final dynamic value; + + /// Type of interaction + late final InteractionOption type; + + /// Name of option + late final String name; /// Any args under this as you can have sub commands - final Map args = {}; + late final Iterable args; - InteractionOption._new(this.value, List> rawOptions) { - for (final option in rawOptions) { - this.args[option["name"] as String] = InteractionOption._new( - option["value"] as dynamic, - (option["options"] ?? List>.empty()) as List>, - ); + /// Option choices + late final Iterable choices; + + InteractionOption._new(Map raw) { + this.value = raw["value"] as dynamic; + this.name = raw["name"] as String; + + if (raw["options"] != null) { + this.args = (raw["options"] as List>).map((e) => InteractionOption._new(e)); + } + + if (raw["choices"] != null) { + this.choices = (raw["options"] as List>).map((e) => ArgChoice(e["name"] as String, e["value"])); } } } diff --git a/nyxx.interactions/lib/src/models/SlashCommand.dart b/nyxx.interactions/lib/src/models/SlashCommand.dart index 52449fba..fb040d09 100644 --- a/nyxx.interactions/lib/src/models/SlashCommand.dart +++ b/nyxx.interactions/lib/src/models/SlashCommand.dart @@ -2,30 +2,51 @@ part of nyxx_interactions; /// A slash command, can only be instantiated through a method on [Interactions] class SlashCommand { + Snowflake? _id; + + Snowflake get id { + if (!this.isRegistered || _id == null) { + throw new StateError("There is no id if command is not registered"); + } + + return _id!; + } + /// Command name to be shown to the user in the Slash Command UI late final String name; + /// Command description shown to the user in the Slash Command UI late final String description; + /// The guild that the slash Command is registered in. This can be null if its a global command. late final Cacheable? guild; + /// The arguments that the command takes late final List args; + /// If the command is a global on, false if restricted to a guild. - late final bool isGlobal; + bool get isGlobal => this.guild == null; + /// If the command has been registered with the discord api late bool isRegistered = false; late final Nyxx _client; - SlashCommand._new(this._client, this.name, this.description, this.args, {this.guild}) { - this.isGlobal = guild == null; - } + SlashCommand._new(this._client, this.name, this.description, this.args, {this.guild}); Future _register() async { - final options = args.map((e) => e._build()); + final options = args.map((e) => e._build()).toList(); + + var path = "/applications/${this._client.app.id.toString()}"; + + if (this.guild != null) { + path += "/guilds/${this.guild!.id}"; + } + + path += "/commands"; final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/${this._client.app.id.toString()}/commands", + path, "POST", body: {"name": this.name, "description": this.description, "options": options.isNotEmpty ? options : null}, ); @@ -34,6 +55,7 @@ class SlashCommand { return Future.error(response); } + this._id = Snowflake((response as HttpResponseSuccess).jsonBody["id"]); this.isRegistered = true; return this; } diff --git a/nyxx.interactions/lib/src/models/commandArgs/ArgChoice.dart b/nyxx.interactions/lib/src/models/commandArgs/ArgChoice.dart index 61c2eab0..a054299a 100644 --- a/nyxx.interactions/lib/src/models/commandArgs/ArgChoice.dart +++ b/nyxx.interactions/lib/src/models/commandArgs/ArgChoice.dart @@ -1,20 +1,18 @@ part of nyxx_interactions; /// A specified choice for a slash command argument. -class ArgChoice { +class ArgChoice implements Builder { /// This options name. - late final String name; + final String name; /// This is the options value, must be int or string - late final dynamic value; + final dynamic value; /// A Choice for the user to input in int & string args. You can only have an int or string option. - ArgChoice(this.name, dynamic value) { + ArgChoice(this.name, this.value) { if (value is! int && value is! String) { throw ArgumentError("Please send a string if its a string arg or an int if its an int arg"); } - - this.value = value; } Map _build() => {"name": this.name, "value": this.value}; diff --git a/nyxx.interactions/lib/src/models/commandArgs/CommandArg.dart b/nyxx.interactions/lib/src/models/commandArgs/CommandArg.dart index 21669167..915c53e2 100644 --- a/nyxx.interactions/lib/src/models/commandArgs/CommandArg.dart +++ b/nyxx.interactions/lib/src/models/commandArgs/CommandArg.dart @@ -1,27 +1,30 @@ part of nyxx_interactions; -/// The type that a user should input for a [SlashArg] -enum CommandArgType { +/// The type that a user should input for a [CommandArg] +class CommandArgType extends IEnum { /// Specify an arg as a sub command - subCommand, + static const subCommand = const CommandArgType(1); /// Specify an arg as a sub command group - subCommandGroup, + static const subCommandGroup = const CommandArgType(2); /// Specify an arg as a string - string, + static const string = const CommandArgType(3); /// Specify an arg as an int - integer, + static const integer = const CommandArgType(4); /// Specify an arg as a bool - boolean, + static const boolean = const CommandArgType(5); /// Specify an arg as a user e.g @HarryET#2954 - user, + static const user = const CommandArgType(6); /// Specify an arg as a channel e.g. #Help - channel, + static const channel = const CommandArgType(7); /// Specify an arg as a role e.g. @RoleName - role, + static const role = const CommandArgType(8); + + /// Create new instance of CommandArgType + const CommandArgType(int value) : super(value); } /// An argument for a [SlashCommand]. -class CommandArg { +class CommandArg implements Builder { /// The type of arg that will be later changed to an INT value, their values can be seen in the table below: /// | Name | Value | /// |-------------------|-------| @@ -57,23 +60,13 @@ class CommandArg { CommandArg(this.type, this.name, this.description, {this.defaultArg = false, this.required = false, this.choices, this.options}); - Map _build() { - final subOptions = this.options != null - ? this.options!.map((e) => e._build()) - : null; - - final rawChoices = this.choices != null - ? this.choices!.map((e) => e._build()) - : null; - - return { - "type": (this.type.index) + 1, + Map _build() => { + "type": this.type.value, "name": this.name, "description": this.description, "default": this.defaultArg, "required": this.required, - "choices": rawChoices, - "options": subOptions + if (this.choices != null) "choices": this.choices!.map((e) => e._build()), + if (this.options != null) "options": this.options!.map((e) => e._build()) }; - } } diff --git a/nyxx.interactions/pubspec.yaml b/nyxx.interactions/pubspec.yaml index 34558465..633712b3 100644 --- a/nyxx.interactions/pubspec.yaml +++ b/nyxx.interactions/pubspec.yaml @@ -12,3 +12,7 @@ environment: dependencies: logging: "^1.0.0-nullsafety.0" nyxx: "^1.1.0-dev.2" + +dependency_overrides: + nyxx: + path: "../nyxx" \ No newline at end of file diff --git a/nyxx/lib/src/Nyxx.dart b/nyxx/lib/src/Nyxx.dart index 7285af93..67894e2f 100644 --- a/nyxx/lib/src/Nyxx.dart +++ b/nyxx/lib/src/Nyxx.dart @@ -218,7 +218,7 @@ class Nyxx implements Disposable { final errorsPort = ReceivePort(); errorsPort.listen((err) { - _logger.severe("ERROR: ${err[0]} \n ${err[1]}"); + _logger.severe("ERROR: ${err[0]}; ${err[1]}"); }); Isolate.current.addErrorListener(errorsPort.sendPort); } diff --git a/nyxx/lib/src/internal/_HttpEndpoints.dart b/nyxx/lib/src/internal/_HttpEndpoints.dart index d038bf01..66807642 100644 --- a/nyxx/lib/src/internal/_HttpEndpoints.dart +++ b/nyxx/lib/src/internal/_HttpEndpoints.dart @@ -1441,17 +1441,9 @@ class _HttpEndpoints implements IHttpEndpoints { @override Future<_HttpResponse> sendRawRequest(String url, String method, - {dynamic body, dynamic headers}) async { - final response = await _httpClient + {dynamic body, dynamic headers}) => _httpClient ._execute(BasicRequest._new(url, method: method, body: body)); - if (response is HttpResponseError) { - return Future.error(response); - } - - return Future.value(response); - } - Future<_HttpResponse> _getGatewayBot() => _client._http._execute(BasicRequest._new("/gateway/bot")); diff --git a/nyxx/lib/src/internal/http/HttpResponse.dart b/nyxx/lib/src/internal/http/HttpResponse.dart index 816b4478..715f9d65 100644 --- a/nyxx/lib/src/internal/http/HttpResponse.dart +++ b/nyxx/lib/src/internal/http/HttpResponse.dart @@ -40,7 +40,7 @@ class HttpResponseSuccess extends _HttpResponse { /// Returned when client fails to execute http request. /// Will contain reason why request failed. -class HttpResponseError extends _HttpResponse { +class HttpResponseError extends _HttpResponse implements Error { /// Message why http request failed late String errorMessage; @@ -71,4 +71,7 @@ class HttpResponseError extends _HttpResponse { @override String toString() => "[Code: $errorCode] [Message: $errorMessage]"; + + @override + StackTrace? get stackTrace => null; }