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;
typedef SlashCommandHandlder = FutureOr<void> 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<SlashCommand> _commands = [];
@ -13,6 +18,8 @@ class Interactions {
/// Emitted when a slash command is created by the user.
late final Stream<SlashCommand> onSlashCommandCreated;
final _commandHandlers = <String, SlashCommandHandlder>{};
/// 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<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
///
/// 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(

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.
bool hasResponded = false;
InteractionEvent._new(Nyxx client, Map<String, dynamic> rawJson) {
this._client = client;
this.interaction = Interaction._new(client, rawJson);
InteractionEvent._new(this._client, Map<String, dynamic> 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<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)))) {
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":

View file

@ -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<String, InteractionOption> args;
late final Iterable<InteractionOption> args;
Interaction._new(
this._client,
Map<String, dynamic> raw,
) : super(Snowflake(raw["id"])) {
/// Id of command
late final Snowflake commandId;
Interaction._new(this._client, Map<String, dynamic> 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<String, dynamic>,
);
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<String, dynamic>);
this.commandId = Snowflake(raw["data"]["id"]);
}
Map<String, InteractionOption> _generateArgs(Map<String, dynamic> rawData) {
final args = <String, InteractionOption>{};
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>>,
);
}
Iterable<InteractionOption> _generateArgs(Map<String, dynamic> rawData) sync* {
if (rawData["options"] == null) {
return;
}
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
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<String, InteractionOption> args = {};
late final Iterable<InteractionOption> args;
InteractionOption._new(this.value, List<Map<String, dynamic>> rawOptions) {
for (final option in rawOptions) {
this.args[option["name"] as String] = InteractionOption._new(
option["value"] as dynamic,
(option["options"] ?? List<Map<String, dynamic>>.empty()) as List<Map<String, dynamic>>,
);
/// Option choices
late final Iterable<ArgChoice> choices;
InteractionOption._new(Map<String, dynamic> raw) {
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]
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<Snowflake, Guild>? guild;
/// The arguments that the command takes
late final List<CommandArg> 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<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(
"/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;
}

View file

@ -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<String, dynamic> _build() => {"name": this.name, "value": this.value};

View file

@ -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<int> {
/// 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<String, dynamic> _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<String, dynamic> _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())
};
}
}

View file

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

View file

@ -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);
}

View file

@ -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"));

View file

@ -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;
}