Fixes for slash commands; Implemented Commander like interface for interactions

This commit is contained in:
Szymon Uglis 2021-02-28 11:05:56 +01:00
parent 975d7b9473
commit bcd12ee369
No known key found for this signature in database
GPG key ID: 112376C5BEE91FE2
11 changed files with 147 additions and 97 deletions

View file

@ -1,7 +1,12 @@
part of nyxx_interactions; part of nyxx_interactions;
typedef SlashCommandHandlder = FutureOr<void> Function(InteractionEvent);
/// Interaction extension for Nyxx. Allows use of: Slash Commands. /// Interaction extension for Nyxx. Allows use of: Slash Commands.
class Interactions { class Interactions {
static const _interactionCreateCommand = "INTERACTION_CREATE";
static const _op0 = 0;
late final Nyxx _client; late final Nyxx _client;
final Logger _logger = Logger("Interactions"); final Logger _logger = Logger("Interactions");
final List<SlashCommand> _commands = []; final List<SlashCommand> _commands = [];
@ -13,6 +18,8 @@ class Interactions {
/// Emitted when a slash command is created by the user. /// Emitted when a slash command is created by the user.
late final Stream<SlashCommand> onSlashCommandCreated; late final Stream<SlashCommand> onSlashCommandCreated;
final _commandHandlers = <String, SlashCommandHandlder>{};
/// Create new instance of the interactions class. /// Create new instance of the interactions class.
Interactions(Nyxx client) { Interactions(Nyxx client) {
this._client = client; this._client = client;
@ -20,19 +27,43 @@ class Interactions {
_client.options.dispatchRawShardEvent = true; _client.options.dispatchRawShardEvent = true;
_logger.info("Interactions ready"); _logger.info("Interactions ready");
client.onReady.listen((event) { client.onReady.listen((event) async {
client.shardManager.rawEvent.listen((event) { client.shardManager.rawEvent.listen((event) {
if (event.rawData["op"] as int == 0) { if (event.rawData["op"] as int == _op0) {
if (event.rawData["t"] as String == "INTERACTION_CREATE") { if (event.rawData["t"] as String == _interactionCreateCommand) {
_events.onSlashCommand.add( _events.onSlashCommand.add(
InteractionEvent._new(client, event.rawData["d"] as Map<String, dynamic>), InteractionEvent._new(client, event.rawData["d"] as Map<String, dynamic>),
); );
} }
} }
}); });
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<CommandArg> 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 /// 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. /// 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; var failed = 0;
for (final command in _commands) { for (final command in _commands) {
if (!command.isRegistered) { if (!command.isRegistered) {
await command._register().then((value) { try {
this._events.onSlashCommandCreated.add(command); final registeredCommand = await command._register();
this._events.onSlashCommandCreated.add(registeredCommand);
success++; success++;
}).catchError(() { } on HttpResponseError catch (e) {
this._logger.severe("Failed registering command: ${e.toString()}");
failed++; failed++;
}); }
} }
} }
_logger.info( _logger.info(

View file

@ -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. /// 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; bool hasResponded = false;
InteractionEvent._new(Nyxx client, Map<String, dynamic> rawJson) { InteractionEvent._new(this._client, Map<String, dynamic> rawJson) {
this._client = client; this.interaction = Interaction._new(this._client, rawJson);
this.interaction = Interaction._new(client, rawJson);
if (this.interaction.type == 1) { if (this.interaction.type == 1) {
this._pong(); 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. /// 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<void> reply({ dynamic content, EmbedBuilder? embed, bool? tts, AllowedMentions? allowedMentions, bool showSource = false, }) async { Future<void> 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)))) { if (DateTime.now().isBefore(this.receivedAt.add(const Duration(minutes: 15)))) {
String url; String url;
if (hasResponded) { if (hasResponded) {
@ -87,6 +86,7 @@ class InteractionEvent {
body: { body: {
"type": showSource ? 4 : 3, "type": showSource ? 4 : 3,
"data": { "data": {
if (hidden) "flags": 1 << 6,
"content": content, "content": content,
"embeds": embed != null ? [BuilderUtility.buildRawEmbed(embed)] : null, "embeds": embed != null ? [BuilderUtility.buildRawEmbed(embed)] : null,
"allowed_mentions": "allowed_mentions":

View file

@ -1,7 +1,7 @@
part of nyxx_interactions; part of nyxx_interactions;
/// The Interaction data. e.g channel, guild and member /// The Interaction data. e.g channel, guild and member
class Interaction extends SnowflakeEntity implements Disposable { class Interaction extends SnowflakeEntity {
/// Reference to bot instance. /// Reference to bot instance.
final Nyxx _client; final Nyxx _client;
@ -27,54 +27,45 @@ class Interaction extends SnowflakeEntity implements Disposable {
late final String name; late final String name;
/// Args of the interaction /// Args of the interaction
late final Map<String, InteractionOption> args; late final Iterable<InteractionOption> args;
Interaction._new( /// Id of command
this._client, late final Snowflake commandId;
Map<String, dynamic> raw,
) : super(Snowflake(raw["id"])) { Interaction._new(this._client, Map<String, dynamic> raw) : super(Snowflake(raw["id"])) {
this.type = raw["type"] as int; this.type = raw["type"] as int;
this.guild = CacheUtility.createCacheableGuild( this.guild = CacheUtility.createCacheableGuild(
_client, _client,
Snowflake( Snowflake(raw["guild_id"],),
raw["guild_id"],
),
); );
this.channel = CacheUtility.createCacheableTextChannel( this.channel = CacheUtility.createCacheableTextChannel(
_client, _client,
Snowflake( Snowflake(raw["channel_id"]),
raw["channel_id"],
),
); );
this.author = EntityUtility.createGuildMember( this.author = EntityUtility.createGuildMember(
_client, _client,
Snowflake( Snowflake(raw["guild_id"]),
raw["guild_id"],
),
raw["member"] as Map<String, dynamic>, raw["member"] as Map<String, dynamic>,
); );
this.token = raw["token"] as String; this.token = raw["token"] as String;
this.version = raw["version"] as int; this.version = raw["version"] as int;
this.name = raw["data"]["name"] as String; this.name = raw["data"]["name"] as String;
this.args = _generateArgs(raw["data"] as Map<String, dynamic>); this.args = _generateArgs(raw["data"] as Map<String, dynamic>);
this.commandId = Snowflake(raw["data"]["id"]);
} }
Map<String, InteractionOption> _generateArgs(Map<String, dynamic> rawData) { Iterable<InteractionOption> _generateArgs(Map<String, dynamic> rawData) sync* {
final args = <String, InteractionOption>{}; if (rawData["options"] == null) {
return;
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<dynamic>.empty()) as List<Map<String, dynamic>>,
);
}
} }
return args; final options = rawData["options"] as List<dynamic>;
for (final option in options) {
yield InteractionOption._new(option as Map<String, dynamic>);
}
} }
@override
Future<void> dispose() => Future.value(null);
} }

View file

@ -3,17 +3,30 @@ part of nyxx_interactions;
/// The option given by the user when sending a command /// The option given by the user when sending a command
class InteractionOption { class InteractionOption {
/// The value given by the user /// 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 /// Any args under this as you can have sub commands
final Map<String, InteractionOption> args = {}; late final Iterable<InteractionOption> args;
InteractionOption._new(this.value, List<Map<String, dynamic>> rawOptions) { /// Option choices
for (final option in rawOptions) { late final Iterable<ArgChoice> choices;
this.args[option["name"] as String] = InteractionOption._new(
option["value"] as dynamic, InteractionOption._new(Map<String, dynamic> raw) {
(option["options"] ?? List<Map<String, dynamic>>.empty()) as List<Map<String, dynamic>>, this.value = raw["value"] as dynamic;
); this.name = raw["name"] as String;
if (raw["options"] != null) {
this.args = (raw["options"] as List<Map<String, dynamic>>).map((e) => InteractionOption._new(e));
}
if (raw["choices"] != null) {
this.choices = (raw["options"] as List<Map<String, dynamic>>).map((e) => ArgChoice(e["name"] as String, e["value"]));
} }
} }
} }

View file

@ -2,30 +2,51 @@ part of nyxx_interactions;
/// A slash command, can only be instantiated through a method on [Interactions] /// A slash command, can only be instantiated through a method on [Interactions]
class SlashCommand { 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 /// Command name to be shown to the user in the Slash Command UI
late final String name; late final String name;
/// Command description shown to the user in the Slash Command UI /// Command description shown to the user in the Slash Command UI
late final String description; late final String description;
/// The guild that the slash Command is registered in. This can be null if its a global command. /// The guild that the slash Command is registered in. This can be null if its a global command.
late final Cacheable<Snowflake, Guild>? guild; late final Cacheable<Snowflake, Guild>? guild;
/// The arguments that the command takes /// The arguments that the command takes
late final List<CommandArg> args; late final List<CommandArg> args;
/// If the command is a global on, false if restricted to a guild. /// 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 /// If the command has been registered with the discord api
late bool isRegistered = false; late bool isRegistered = false;
late final Nyxx _client; late final Nyxx _client;
SlashCommand._new(this._client, this.name, this.description, this.args, {this.guild}) { SlashCommand._new(this._client, this.name, this.description, this.args, {this.guild});
this.isGlobal = guild == null;
}
Future<SlashCommand> _register() async { Future<SlashCommand> _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( final response = await this._client.httpEndpoints.sendRawRequest(
"/applications/${this._client.app.id.toString()}/commands", path,
"POST", "POST",
body: {"name": this.name, "description": this.description, "options": options.isNotEmpty ? options : null}, body: {"name": this.name, "description": this.description, "options": options.isNotEmpty ? options : null},
); );
@ -34,6 +55,7 @@ class SlashCommand {
return Future.error(response); return Future.error(response);
} }
this._id = Snowflake((response as HttpResponseSuccess).jsonBody["id"]);
this.isRegistered = true; this.isRegistered = true;
return this; return this;
} }

View file

@ -1,20 +1,18 @@
part of nyxx_interactions; part of nyxx_interactions;
/// A specified choice for a slash command argument. /// A specified choice for a slash command argument.
class ArgChoice { class ArgChoice implements Builder {
/// This options name. /// This options name.
late final String name; final String name;
/// This is the options value, must be int or string /// 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. /// 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) { 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"); throw ArgumentError("Please send a string if its a string arg or an int if its an int arg");
} }
this.value = value;
} }
Map<String, dynamic> _build() => {"name": this.name, "value": this.value}; Map<String, dynamic> _build() => {"name": this.name, "value": this.value};

View file

@ -1,27 +1,30 @@
part of nyxx_interactions; part of nyxx_interactions;
/// The type that a user should input for a [SlashArg] /// The type that a user should input for a [CommandArg]
enum CommandArgType { class CommandArgType extends IEnum<int> {
/// Specify an arg as a sub command /// Specify an arg as a sub command
subCommand, static const subCommand = const CommandArgType(1);
/// Specify an arg as a sub command group /// Specify an arg as a sub command group
subCommandGroup, static const subCommandGroup = const CommandArgType(2);
/// Specify an arg as a string /// Specify an arg as a string
string, static const string = const CommandArgType(3);
/// Specify an arg as an int /// Specify an arg as an int
integer, static const integer = const CommandArgType(4);
/// Specify an arg as a bool /// Specify an arg as a bool
boolean, static const boolean = const CommandArgType(5);
/// Specify an arg as a user e.g @HarryET#2954 /// 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 /// 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 /// 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]. /// 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: /// The type of arg that will be later changed to an INT value, their values can be seen in the table below:
/// | Name | Value | /// | Name | Value |
/// |-------------------|-------| /// |-------------------|-------|
@ -57,23 +60,13 @@ class CommandArg {
CommandArg(this.type, this.name, this.description, CommandArg(this.type, this.name, this.description,
{this.defaultArg = false, this.required = false, this.choices, this.options}); {this.defaultArg = false, this.required = false, this.choices, this.options});
Map<String, dynamic> _build() { Map<String, dynamic> _build() => {
final subOptions = this.options != null "type": this.type.value,
? 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,
"name": this.name, "name": this.name,
"description": this.description, "description": this.description,
"default": this.defaultArg, "default": this.defaultArg,
"required": this.required, "required": this.required,
"choices": rawChoices, if (this.choices != null) "choices": this.choices!.map((e) => e._build()),
"options": subOptions if (this.options != null) "options": this.options!.map((e) => e._build())
}; };
}
} }

View file

@ -12,3 +12,7 @@ environment:
dependencies: dependencies:
logging: "^1.0.0-nullsafety.0" logging: "^1.0.0-nullsafety.0"
nyxx: "^1.1.0-dev.2" nyxx: "^1.1.0-dev.2"
dependency_overrides:
nyxx:
path: "../nyxx"

View file

@ -218,7 +218,7 @@ class Nyxx implements Disposable {
final errorsPort = ReceivePort(); final errorsPort = ReceivePort();
errorsPort.listen((err) { errorsPort.listen((err) {
_logger.severe("ERROR: ${err[0]} \n ${err[1]}"); _logger.severe("ERROR: ${err[0]}; ${err[1]}");
}); });
Isolate.current.addErrorListener(errorsPort.sendPort); Isolate.current.addErrorListener(errorsPort.sendPort);
} }

View file

@ -1441,17 +1441,9 @@ class _HttpEndpoints implements IHttpEndpoints {
@override @override
Future<_HttpResponse> sendRawRequest(String url, String method, Future<_HttpResponse> sendRawRequest(String url, String method,
{dynamic body, dynamic headers}) async { {dynamic body, dynamic headers}) => _httpClient
final response = await _httpClient
._execute(BasicRequest._new(url, method: method, body: body)); ._execute(BasicRequest._new(url, method: method, body: body));
if (response is HttpResponseError) {
return Future.error(response);
}
return Future.value(response);
}
Future<_HttpResponse> _getGatewayBot() => Future<_HttpResponse> _getGatewayBot() =>
_client._http._execute(BasicRequest._new("/gateway/bot")); _client._http._execute(BasicRequest._new("/gateway/bot"));

View file

@ -40,7 +40,7 @@ class HttpResponseSuccess extends _HttpResponse {
/// Returned when client fails to execute http request. /// Returned when client fails to execute http request.
/// Will contain reason why request failed. /// Will contain reason why request failed.
class HttpResponseError extends _HttpResponse { class HttpResponseError extends _HttpResponse implements Error {
/// Message why http request failed /// Message why http request failed
late String errorMessage; late String errorMessage;
@ -71,4 +71,7 @@ class HttpResponseError extends _HttpResponse {
@override @override
String toString() => String toString() =>
"[Code: $errorCode] [Message: $errorMessage]"; "[Code: $errorCode] [Message: $errorMessage]";
@override
StackTrace? get stackTrace => null;
} }