From f0bf1800f71e31a6a955184c51b516926e702f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=82=E3=81=8E=E3=82=85=E3=81=A1?= Date: Thu, 28 Apr 2022 21:43:04 +0900 Subject: [PATCH 1/2] Update README.md (#43) Fixed a broken link to contributing document. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e80ceb..dd4b57e 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The dartdocs page will always have the documentation for the latest release. ## Contributing to Nyxx -Read [contributing document](https://github.com/l7ssha/nyxx_interactions/blob/development/CONTRIBUTING.md) +Read [contributing document](https://github.com/nyxx-discord/nyxx_interactions/blob/dev/CONTRIBUTING.md) ## Credits From 595b06195c88d168bb747d3a3b48d4d3f6efc5c5 Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Fri, 29 Apr 2022 20:44:04 +0200 Subject: [PATCH 2/2] Implement permissions v2 (#45) * Implement permissions v2 * Correct requiredPermissions * Implement fetching command permission overrides * Update lock file command sync * Update unit tests * Fix global slash commands not having permission overrides * Document members * Allow fetching permission overrides for global commands. * Release 4.2.0 * Fix linter warnings --- CHANGELOG.md | 6 + example/basic.dart | 4 +- lib/nyxx_interactions.dart | 1 + .../builders/command_permission_builder.dart | 3 + lib/src/builders/slash_command_builder.dart | 29 +++- lib/src/interactions.dart | 22 ++- lib/src/internal/interaction_endpoints.dart | 46 ++++-- .../internal/sync/lock_file_command_sync.dart | 152 ++++++------------ .../internal/sync/manual_command_sync.dart | 2 +- lib/src/models/slash_command.dart | 55 ++++++- lib/src/models/slash_command_permission.dart | 95 +++++++++++ pubspec.yaml | 2 +- test/unit/event_controller.dart | 1 - test/unit/model.dart | 13 +- test/unit/slash_command_builder.dart | 17 +- test/unit/utils.dart | 1 - 16 files changed, 302 insertions(+), 147 deletions(-) create mode 100644 lib/src/models/slash_command_permission.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e12bc..d0d9622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 4.2.0 +__29.04.2022__ + +- feature: Add support for the permissions v2 system +- deprecations: Deprecated all command permission builders, the `defaultPermissions` and `permissions` fields of `SlashCommandBuilder`, the `bulkOverrideGuildCommandsPermissions` endpoint method and `defaultPermissions` from `SlashCommand`. + ## 4.1.0 __09.04.2022__ diff --git a/example/basic.dart b/example/basic.dart index 62b1bdf..e854d63 100644 --- a/example/basic.dart +++ b/example/basic.dart @@ -21,9 +21,7 @@ final singleCommand = SlashCommandBuilder("help", "This is example help command" // for main handler because only sub commands will be invokable. // In list for options you can create new instances of sub commands with // commands handlers that command could be responded by bot. -final subCommand = SlashCommandBuilder("game", "This is example game command", [ - subCommandFlipGame -]); +final subCommand = SlashCommandBuilder("game", "This is example game command", [subCommandFlipGame]); // Subcommand event handler receives same SlashCommandInteraction parameter with all // info and tools need to respond to an interaction diff --git a/lib/nyxx_interactions.dart b/lib/nyxx_interactions.dart index a3780d0..3fb5bb9 100644 --- a/lib/nyxx_interactions.dart +++ b/lib/nyxx_interactions.dart @@ -43,6 +43,7 @@ export 'src/models/interaction.dart' show IComponentInteraction, IInteraction, IButtonInteraction, IMultiselectInteraction, ISlashCommandInteraction, IModalInteraction; export 'src/models/interaction_data_resolved.dart' show IInteractionDataResolved, IPartialChannel; export 'src/models/interaction_option.dart' show IInteractionOption; +export 'src/models/slash_command_permission.dart' show ISlashCommandPermissionOverride, ISlashCommandPermissionOverrides, SlashCommandPermissionType; export 'src/models/slash_command.dart' show ISlashCommand; export 'src/models/slash_command_type.dart' show SlashCommandType; diff --git a/lib/src/builders/command_permission_builder.dart b/lib/src/builders/command_permission_builder.dart index ee514a1..8178d9b 100644 --- a/lib/src/builders/command_permission_builder.dart +++ b/lib/src/builders/command_permission_builder.dart @@ -1,6 +1,7 @@ import 'package:nyxx/nyxx.dart'; /// Used to define permissions for a particular command. +@Deprecated('Use SlashCommandBuilder.canBeUsedInDm and SlashCommandBuilder.requiresPermissions instead') abstract class CommandPermissionBuilderAbstract extends Builder { int get type; @@ -20,6 +21,7 @@ abstract class CommandPermissionBuilderAbstract extends Builder { } /// A permission for a single role that can be used in [SlashCommandBuilder] +@Deprecated('Use SlashCommandBuilder.canBeUsedInDm and SlashCommandBuilder.requiresPermissions instead') class RoleCommandPermissionBuilder extends CommandPermissionBuilderAbstract { @override late final int type = 1; @@ -32,6 +34,7 @@ class RoleCommandPermissionBuilder extends CommandPermissionBuilderAbstract { } /// A permission for a single user that can be used in [SlashCommandBuilder] +@Deprecated('Use SlashCommandBuilder.canBeUsedInDm and SlashCommandBuilder.requiresPermissions instead') class UserCommandPermissionBuilder extends CommandPermissionBuilderAbstract { @override late final int type = 2; diff --git a/lib/src/builders/slash_command_builder.dart b/lib/src/builders/slash_command_builder.dart index 915cdb9..fa91579 100644 --- a/lib/src/builders/slash_command_builder.dart +++ b/lib/src/builders/slash_command_builder.dart @@ -21,6 +21,7 @@ class SlashCommandBuilder extends Builder { final String? description; /// If people can use the command by default or if they need permissions to use it. + @Deprecated('Use canBeUsedInDm and requiresPermissions instead') final bool defaultPermissions; /// The guild that the slash Command is registered in. This can be null if its a global command. @@ -30,6 +31,7 @@ class SlashCommandBuilder extends Builder { List options; /// Permission overrides for the command + @Deprecated('Use canBeUsedInDm and requiresPermissions instead') List? permissions; /// Target of slash command if different that SlashCommandTarget.chat - slash command will @@ -39,9 +41,27 @@ class SlashCommandBuilder extends Builder { /// Handler for SlashCommandBuilder SlashCommandHandler? handler; + /// Whether this slash command can be used in a DM channel with the bot. + final bool canBeUsedInDm; + + /// A set of permissions required by users in guilds to execute this command. + /// + /// The integer to use for a permission can be obtained by using [PermissionsConstants]. If a member has any of the permissions combined with the bitwise OR + /// operator, they will be allowed to execute the command. + int? requiredPermissions; + /// A slash command, can only be instantiated through a method on [Interactions] - SlashCommandBuilder(this.name, this.description, this.options, - {this.defaultPermissions = true, this.permissions, this.guild, this.type = SlashCommandType.chat}) { + SlashCommandBuilder( + this.name, + this.description, + this.options, { + this.canBeUsedInDm = true, + this.requiredPermissions, + this.guild, + this.type = SlashCommandType.chat, + this.defaultPermissions = true, + this.permissions, + }) { if (!slashCommandNameRegex.hasMatch(name)) { throw ArgumentError("Command name has to match regex: ${slashCommandNameRegex.pattern}"); } @@ -59,9 +79,11 @@ class SlashCommandBuilder extends Builder { RawApiMap build() => { "name": name, if (type == SlashCommandType.chat) "description": description, - "default_permission": defaultPermissions, if (options.isNotEmpty) "options": options.map((e) => e.build()).toList(), "type": type.value, + "dm_permission": canBeUsedInDm, + if (requiredPermissions != null) "default_member_permissions": requiredPermissions.toString(), + "default_permission": defaultPermissions, }; void setId(Snowflake id) => _id = id; @@ -69,6 +91,7 @@ class SlashCommandBuilder extends Builder { Snowflake get id => _id; /// Register a permission + @Deprecated('Use canBeUsedInDm and requiresPermissions instead') void addPermission(CommandPermissionBuilderAbstract permission) { permissions ??= []; diff --git a/lib/src/interactions.dart b/lib/src/interactions.dart index cf03a7b..f278b40 100644 --- a/lib/src/interactions.dart +++ b/lib/src/interactions.dart @@ -14,6 +14,7 @@ import 'package:nyxx_interactions/src/internal/sync/commands_sync.dart'; import 'package:nyxx_interactions/src/internal/sync/manual_command_sync.dart'; import 'package:nyxx_interactions/src/internal/utils.dart'; import 'package:nyxx_interactions/src/models/command_option.dart'; +import 'package:nyxx_interactions/src/models/slash_command_permission.dart'; import 'package:nyxx_interactions/src/typedefs.dart'; import 'package:nyxx_interactions/src/events/interaction_event.dart'; import 'package:nyxx_interactions/src/builders/command_option_builder.dart'; @@ -76,6 +77,7 @@ abstract class IInteractions { /// Interaction extension for Nyxx. Allows use of: Slash Commands. class Interactions implements IInteractions { static const _interactionCreateCommand = "INTERACTION_CREATE"; + static const _commandPermissionsUpdate = "APPLICATION_COMMAND_PERMISSIONS_UPDATE"; final Logger _logger = Logger("Interactions"); final _commandBuilders = []; @@ -85,6 +87,8 @@ class Interactions implements IInteractions { final _autocompleteHandlers = {}; final _multiselectHandlers = {}; + final permissionOverridesCache = >{}; + @override late final IEventController events; @@ -106,7 +110,7 @@ class Interactions implements IInteractions { /// Create new instance of the interactions class. Interactions(this.backend) { events = EventController(); - interactionsEndpoints = InteractionsEndpoints(client); + interactionsEndpoints = InteractionsEndpoints(client, this); backend.setup(); @@ -145,6 +149,14 @@ class Interactions implements IInteractions { default: _logger.warning("Unknown interaction type: [$type]; Payload: ${jsonEncode(rawData)}"); } + } else if (rawData["op"] == 0 && rawData["t"] == _commandPermissionsUpdate) { + final overrides = SlashCommandPermissionOverrides(rawData["d"] as RawApiMap, client); + + Snowflake guildId = Snowflake(rawData["d"]["guild_id"]); + Snowflake commandId = Snowflake(rawData["d"]["id"]); + + permissionOverridesCache[guildId] ??= {}; + permissionOverridesCache[guildId]![commandId] = overrides; } }); } @@ -190,7 +202,13 @@ class Interactions implements IInteractions { _commands.add(command); } - await interactionsEndpoints.bulkOverrideGuildCommandsPermissions(client.appId, entry.key, entry.value); + if (entry.value.any((element) => element.permissions?.isNotEmpty ?? false)) { + _logger.warning( + 'Using deprecated permissions endpoint. To fix, use SlashCommandBuilder.canBeUsedInDm and SlashCommandBuilder.requiresPermissions' + ' instead of SlashCommandBuilder.permissions', + ); + await interactionsEndpoints.bulkOverrideGuildCommandsPermissions(client.appId, entry.key, entry.value); + } } for (final globalCommandBuilder in entry.value) { diff --git a/lib/src/internal/interaction_endpoints.dart b/lib/src/internal/interaction_endpoints.dart index 7259904..142f47a 100644 --- a/lib/src/internal/interaction_endpoints.dart +++ b/lib/src/internal/interaction_endpoints.dart @@ -1,10 +1,10 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx_interactions/src/builders/modal_builder.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx_interactions/src/interactions.dart'; import 'package:nyxx_interactions/src/models/slash_command.dart'; -import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; -import 'package:nyxx_interactions/src/builders/arg_choice_builder.dart'; +import 'package:nyxx_interactions/src/models/slash_command_permission.dart'; abstract class IInteractionsEndpoints { /// Sends followup for interaction with given [token]. IMessage will be created with [builder] @@ -73,16 +73,22 @@ abstract class IInteractionsEndpoints { Stream bulkOverrideGuildCommands(Snowflake applicationId, Snowflake guildId, Iterable builders); /// Overrides permissions for guild commands + @Deprecated("This endpoint requires OAuth2 authentication, which nyxx_interactions doesn't support." + " Use SlashCommandBuilder.canBeUsedInDm and SlashCommandBuilder.requiresPermissions instead.") Future bulkOverrideGuildCommandsPermissions(Snowflake applicationId, Snowflake guildId, Iterable builders); /// Responds to autocomplete interaction Future respondToAutocomplete(Snowflake interactionId, String token, List builders); + + /// Fetch the command permission overrides for a command in a guild. + Future fetchCommandOverrides(Snowflake commandId, Snowflake guildId); } class InteractionsEndpoints implements IInteractionsEndpoints { final INyxx _client; + final Interactions _interactions; - InteractionsEndpoints(this._client); + InteractionsEndpoints(this._client, this._interactions); @override Future acknowledge(String token, String interactionId, bool hidden, int opCode) async { @@ -200,7 +206,7 @@ class InteractionsEndpoints implements IInteractionsEndpoints { } for (final rawRes in (response as IHttpResponseSucess).jsonBody as List) { - yield SlashCommand(rawRes as RawApiMap, _client); + yield SlashCommand(rawRes as RawApiMap, _interactions); } } @@ -213,7 +219,7 @@ class InteractionsEndpoints implements IInteractionsEndpoints { } for (final rawRes in (response as IHttpResponseSucess).jsonBody as List) { - yield SlashCommand(rawRes as RawApiMap, _client); + yield SlashCommand(rawRes as RawApiMap, _interactions); } } @@ -243,7 +249,7 @@ class InteractionsEndpoints implements IInteractionsEndpoints { return Future.error(response); } - return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _client); + return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _interactions); } @override @@ -255,7 +261,7 @@ class InteractionsEndpoints implements IInteractionsEndpoints { return Future.error(response); } - return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _client); + return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _interactions); } @override @@ -266,7 +272,7 @@ class InteractionsEndpoints implements IInteractionsEndpoints { return Future.error(response); } - return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _client); + return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _interactions); } @override @@ -278,7 +284,7 @@ class InteractionsEndpoints implements IInteractionsEndpoints { } for (final commandSlash in (response as IHttpResponseSucess).jsonBody as List) { - yield SlashCommand(commandSlash as RawApiMap, _client); + yield SlashCommand(commandSlash as RawApiMap, _interactions); } } @@ -290,7 +296,7 @@ class InteractionsEndpoints implements IInteractionsEndpoints { return Future.error(response); } - return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _client); + return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _interactions); } @override @@ -302,10 +308,12 @@ class InteractionsEndpoints implements IInteractionsEndpoints { } for (final commandSlash in (response as IHttpResponseSucess).jsonBody as List) { - yield SlashCommand(commandSlash as RawApiMap, _client); + yield SlashCommand(commandSlash as RawApiMap, _interactions); } } + @Deprecated("This endpoint requires OAuth2 authentication, which nyxx_interactions doesn't support." + " Use SlashCommandBuilder.canBeUsedInDm and SlashCommandBuilder.requiresPermissions instead.") Future bulkOverrideGlobalCommandsPermissions(Snowflake applicationId, Iterable builders) async { final globalBody = builders .where((builder) => builder.permissions != null && builder.permissions!.isNotEmpty) @@ -319,6 +327,8 @@ class InteractionsEndpoints implements IInteractionsEndpoints { } @override + @Deprecated("This endpoint requires OAuth2 authentication, which nyxx_interactions doesn't support." + " Use SlashCommandBuilder.canBeUsedInDm and SlashCommandBuilder.requiresPermissions instead.") Future bulkOverrideGuildCommandsPermissions(Snowflake applicationId, Snowflake guildId, Iterable builders) async { final guildBody = builders .where((b) => b.permissions != null && b.permissions!.isNotEmpty) @@ -367,4 +377,16 @@ class InteractionsEndpoints implements IInteractionsEndpoints { return Future.error(response); } } + + /// Fetch the command permission overrides for a command in a guild. + @override + Future fetchCommandOverrides(Snowflake commandId, Snowflake guildId) async { + final response = await _client.httpEndpoints.sendRawRequest("/applications/${_client.appId}/guilds/$guildId/commands/$commandId/permissions", "GET"); + + if (response is IHttpResponseError) { + return Future.error(response); + } + + return SlashCommandPermissionOverrides((response as IHttpResponseSucess).jsonBody as Map, _client); + } } diff --git a/lib/src/internal/sync/lock_file_command_sync.dart b/lib/src/internal/sync/lock_file_command_sync.dart index 8810265..766b556 100644 --- a/lib/src/internal/sync/lock_file_command_sync.dart +++ b/lib/src/internal/sync/lock_file_command_sync.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; -import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/src/builders/arg_choice_builder.dart'; import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; import 'package:nyxx_interactions/src/builders/command_option_builder.dart'; import 'package:nyxx_interactions/src/internal/sync/commands_sync.dart'; @@ -14,120 +14,70 @@ class LockFileCommandSync implements ICommandsSync { @override FutureOr shouldSync(Iterable commands) async { - final lockFile = File("./nyxx_interactions.lock"); - final lockFileMapData = {}; + File lockFile = File('./nyxx_interactions.lock'); - for (final c in commands) { - lockFileMapData[c.name] = _LockfileCommand( - c.name, - c.description, - c.guild, - c.defaultPermissions, - c.permissions?.map((p) => _LockfilePermission(p.type, p.id, p.hasPermission)) ?? [], - c.options.map((o) => _LockfileOption(o.type.value, o.name, o.description, o.options ?? [])), - ).generateHash(); - } + Map hashes = { + for (final command in commands) command.name: generateBuilderHash(command).bytes.map((e) => e.toRadixString(16)).join(), + }; if (!lockFile.existsSync()) { - await lockFile.writeAsString(jsonEncode(lockFileMapData)); + lockFile.writeAsStringSync(jsonEncode(hashes)); return true; } - final lockfileData = jsonDecode(lockFile.readAsStringSync()) as _LockfileCommand; + try { + Map lockFileContents = (jsonDecode(lockFile.readAsStringSync()) as Map).cast(); - if (lockFileMapData == lockfileData) { - return false; + for (final entry in hashes.entries) { + if (lockFileContents[entry.key] != entry.value) { + return false; + } + } + } on FormatException { + lockFile.writeAsStringSync(jsonEncode(hashes)); + return true; } - await lockFile.writeAsString(jsonEncode(lockFileMapData)); - return true; + return false; } } -class _LockfileCommand { - final String name; - final Snowflake? guild; - final bool defaultPermissions; - final Iterable<_LockfilePermission> permissions; - final String? description; - final Iterable<_LockfileOption> options; - - _LockfileCommand(this.name, this.description, this.guild, this.defaultPermissions, this.permissions, this.options); - - String generateHash() => md5.convert(utf8.encode(jsonEncode(this))).toString(); - - @override - bool operator ==(Object other) { - if (other is! _LockfileCommand) { - return false; - } - - if (other.defaultPermissions != defaultPermissions || other.name != name || other.guild != guild || other.defaultPermissions != defaultPermissions) { - return false; - } - - return true; - } - - @override - int get hashCode => name.hashCode + guild.hashCode + defaultPermissions.hashCode + permissions.hashCode + description.hashCode + options.hashCode; +Digest generateBuilderHash(SlashCommandBuilder builder) { + return sha256.convert([ + ...utf8.encode(builder.name), + 0, // Delimiter + ...utf8.encode(builder.description ?? ''), + 0, // Delimiter + builder.guild?.id ?? 0, + ...builder.options.map((o) => generateOptionHash(o).bytes).expand((e) => e), + builder.type.value, + builder.canBeUsedInDm ? 0 : 1, + builder.requiredPermissions ?? 0, + ]); } -class _LockfileOption { - final int type; - final String name; - final String? description; - - late final Iterable<_LockfileOption> options; - - _LockfileOption(this.type, this.name, this.description, Iterable options) { - this.options = options.map( - (o) => _LockfileOption( - o.type.value, - o.name, - o.description, - o.options ?? [], - ), - ); - } - - @override - bool operator ==(Object other) { - if (other is! _LockfileOption) { - return false; - } - - if (other.type != type || other.name != name || other.description != description) { - return false; - } - - return true; - } - - @override - int get hashCode => type.hashCode + name.hashCode + description.hashCode; +Digest generateOptionHash(CommandOptionBuilder builder) { + return sha256.convert([ + ...utf8.encode(builder.name), + 0, + ...utf8.encode(builder.description), + 0, + builder.defaultArg ? 0 : 1, + builder.required ? 0 : 1, + if (builder.choices != null) ...builder.choices!.map((e) => generateChoicesHash(e).bytes).expand((e) => e), + if (builder.options != null) ...builder.options!.map((o) => generateOptionHash(o).bytes).expand((e) => e), + if (builder.channelTypes != null) ...builder.channelTypes!.map((e) => e.value), + builder.autoComplete ? 0 : 1, + if (builder.min != null) builder.min.hashCode, + if (builder.max != null) builder.max.hashCode, + ]); } -class _LockfilePermission { - final int permissionType; - final Snowflake? permissionEntityId; - final bool permissionsGranted; - - const _LockfilePermission(this.permissionType, this.permissionEntityId, this.permissionsGranted); - - @override - bool operator ==(Object other) { - if (other is! _LockfilePermission) { - return false; - } - - if (other.permissionType != permissionType || other.permissionEntityId != permissionEntityId || other.permissionsGranted != permissionsGranted) { - return false; - } - - return true; - } - - @override - int get hashCode => permissionType.hashCode + permissionEntityId.hashCode + permissionsGranted.hashCode; +Digest generateChoicesHash(ArgChoiceBuilder builder) { + return sha256.convert([ + ...utf8.encode(builder.name), + 0, + ...utf8.encode(builder.value.toString()), + 0, + ]); } diff --git a/lib/src/internal/sync/manual_command_sync.dart b/lib/src/internal/sync/manual_command_sync.dart index 837d795..57c5744 100644 --- a/lib/src/internal/sync/manual_command_sync.dart +++ b/lib/src/internal/sync/manual_command_sync.dart @@ -5,7 +5,7 @@ import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; /// Manually define command syncing rules class ManualCommandSync implements ICommandsSync { - /// If commands & permissions should be overridden on next run. + /// If commands should be overridden on next run. final bool sync; /// Manually define command syncing rules diff --git a/lib/src/models/slash_command.dart b/lib/src/models/slash_command.dart index 4730a5f..f878ef1 100644 --- a/lib/src/models/slash_command.dart +++ b/lib/src/models/slash_command.dart @@ -1,9 +1,10 @@ import 'package:nyxx/nyxx.dart'; // ignore: implementation_imports import 'package:nyxx/src/internal/cache/cacheable.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx_interactions/src/interactions.dart'; import 'package:nyxx_interactions/src/models/command_option.dart'; - -import 'package:nyxx_interactions/src/models/slash_command_type.dart'; +import 'package:nyxx_interactions/src/models/slash_command_permission.dart'; abstract class ISlashCommand implements SnowflakeEntity { /// Unique id of the parent application @@ -25,7 +26,23 @@ abstract class ISlashCommand implements SnowflakeEntity { Cacheable? get guild; /// Whether the command is enabled by default when the app is added to a guild + @Deprecated('Use canBeUsedInDm and requiresPermissions instead') bool get defaultPermissions; + + /// Whether this slash command can be used in a DM channel with the bot. + bool get canBeUsedInDm; + + /// A set of permissions required by users in guilds to execute this command. + /// + /// The integer to use for a permission can be obtained by using [PermissionsConstants]. If a member has any of the permissions combined with the bitwise OR + /// operator, they will be allowed to execute the command. + int get requiredPermissions; + + /// If this command is a guild command, the permission overrides attached to this command, `null` otherwise. + Cacheable? get permissionOverrides; + + /// Get the permission overrides for this command in a specific guild. + Cacheable getPermissionOverridesInGuild(Snowflake guildId); } /// Represents slash command that is returned from Discord API. @@ -56,15 +73,39 @@ class SlashCommand extends SnowflakeEntity implements ISlashCommand { /// Whether the command is enabled by default when the app is added to a guild @override + @Deprecated('Use canBeUsedInDm and requiresPermissions instead') late final bool defaultPermissions; - /// Creates na instance of [SlashCommand] - SlashCommand(RawApiMap raw, INyxx client) : super(Snowflake(raw["id"])) { + /// Whether this slash command can be used in a DM channel with the bot. + @override + late final bool canBeUsedInDm; + + /// A set of permissions required by users in guilds to execute this command. + /// + /// The integer to use for a permission can be obtained by using [PermissionsConstants]. If a member has any of the permissions combined with the bitwise OR + /// operator, they will be allowed to execute the command. + @override + late final int requiredPermissions; + + @override + late final Cacheable? permissionOverrides; + + final Interactions _interactions; + + /// Creates an instance of [SlashCommand] + SlashCommand(RawApiMap raw, this._interactions) : super(Snowflake(raw["id"])) { applicationId = Snowflake(raw["application_id"]); name = raw["name"] as String; description = raw["description"] as String; type = SlashCommandType(raw["type"] as int? ?? 1); - guild = raw["guild_id"] != null ? GuildCacheable(client, Snowflake(raw["guild_id"])) : null; + guild = raw["guild_id"] != null ? GuildCacheable(_interactions.client, Snowflake(raw["guild_id"])) : null; + canBeUsedInDm = raw["dm_permission"] as bool? ?? true; + requiredPermissions = int.parse(raw["default_member_permissions"] as String? ?? "0"); + + if (guild != null) { + permissionOverrides = SlashCommandPermissionOverridesCacheable(id, guild!.id, _interactions); + } + defaultPermissions = raw["default_permission"] as bool? ?? true; options = [ @@ -72,4 +113,8 @@ class SlashCommand extends SnowflakeEntity implements ISlashCommand { for (final optionRaw in raw["options"]) CommandOption(optionRaw as RawApiMap) ]; } + + @override + Cacheable getPermissionOverridesInGuild(Snowflake guildId) => + SlashCommandPermissionOverridesCacheable(id, guildId, _interactions); } diff --git a/lib/src/models/slash_command_permission.dart b/lib/src/models/slash_command_permission.dart new file mode 100644 index 0000000..84c1e20 --- /dev/null +++ b/lib/src/models/slash_command_permission.dart @@ -0,0 +1,95 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/src/interactions.dart'; + +/// The type of entiity that a command permission override is targeting. +class SlashCommandPermissionType extends IEnum { + /// The permission override applies to a role. + static const SlashCommandPermissionType role = SlashCommandPermissionType._(1); + + /// The permission override applies to a user. + static const SlashCommandPermissionType user = SlashCommandPermissionType._(2); + + /// The permission override applies to a channel. + static const SlashCommandPermissionType channel = SlashCommandPermissionType._(3); + + const SlashCommandPermissionType._(int value) : super(value); +} + +/// A single permission override for a command. +abstract class ISlashCommandPermissionOverride { + /// The type of this override. + SlashCommandPermissionType get type; + + /// The ID of the entity targeted by this override. + Snowflake get id; + + /// Whether this override allows or denies the command permission. + bool get allowed; + + /// Whether this override represents all users in a guild. + bool get isEveryone; + + /// Whether this override represents all channels in a guild. + bool get isAllChannels; +} + +class SlashCommandPermissionOverride implements ISlashCommandPermissionOverride { + @override + late final SlashCommandPermissionType type; + @override + late final Snowflake id; + @override + late final bool allowed; + @override + late final bool isEveryone; + @override + late final bool isAllChannels; + + SlashCommandPermissionOverride(RawApiMap raw, Snowflake guildId, INyxx client) { + type = SlashCommandPermissionType._(raw['type'] as int); + id = Snowflake(raw['id'] as String); + allowed = raw['permission'] as bool; + + isEveryone = id == guildId; + isAllChannels = id == guildId.id - 1; + } +} + +/// A collection of permission overrides attached to a slash command. +abstract class ISlashCommandPermissionOverrides implements SnowflakeEntity { + /// The permissions attached to the command. + List get permissionOverrides; +} + +class SlashCommandPermissionOverrides extends SnowflakeEntity implements ISlashCommandPermissionOverrides { + @override + late final List permissionOverrides; + + SlashCommandPermissionOverrides(RawApiMap raw, INyxx client) : super(Snowflake(raw['id'])) { + permissionOverrides = [ + for (final override in (raw['permissions'] as List).cast>()) + SlashCommandPermissionOverride(override, Snowflake(raw["guild_id"]), client), + ]; + } +} + +class SlashCommandPermissionOverridesCacheable extends Cacheable { + final Snowflake guildId; + final Interactions interactions; + + SlashCommandPermissionOverridesCacheable(Snowflake id, this.guildId, this.interactions) : super(interactions.client, id); + + @override + Future download() async { + SlashCommandPermissionOverrides fetchedOverrides = + await interactions.interactionsEndpoints.fetchCommandOverrides(id, guildId) as SlashCommandPermissionOverrides; + + interactions.permissionOverridesCache[guildId] ??= {}; + interactions.permissionOverridesCache[guildId]![id] = fetchedOverrides; + + return fetchedOverrides; + } + + @override + SlashCommandPermissionOverrides? getFromCache() => interactions.permissionOverridesCache[guildId]?[id]; +} diff --git a/pubspec.yaml b/pubspec.yaml index 87facab..934f0bb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_interactions -version: 4.1.0 +version: 4.2.0 description: Nyxx Interactions Module. Discord library for Dart. Simple, robust framework for creating discord bots for Dart language. homepage: https://github.com/nyxx-discord/nyxx repository: https://github.com/nyxx-discord/nyxx diff --git a/test/unit/event_controller.dart b/test/unit/event_controller.dart index 55d0609..26a9a98 100755 --- a/test/unit/event_controller.dart +++ b/test/unit/event_controller.dart @@ -1,4 +1,3 @@ -import 'package:nyxx_interactions/nyxx_interactions.dart'; import 'package:nyxx_interactions/src/internal/event_controller.dart'; import 'package:test/test.dart'; diff --git a/test/unit/model.dart b/test/unit/model.dart index b4315ea..785805c 100644 --- a/test/unit/model.dart +++ b/test/unit/model.dart @@ -1,12 +1,13 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx_interactions/src/interactions.dart'; import 'package:nyxx_interactions/src/models/arg_choice.dart'; import 'package:nyxx_interactions/src/models/command_option.dart'; import 'package:nyxx_interactions/src/models/interaction_option.dart'; import 'package:nyxx_interactions/src/models/slash_command.dart'; import 'package:test/test.dart'; -import '../mocks/nyxx_rest.mocks.dart'; +import '../mocks/nyxx_websocket.mocks.dart'; main() { test('ArgChoice', () { @@ -39,7 +40,8 @@ main() { }); test('SlashCommand', () { - final client = NyxxRestMock(); + final client = NyxxWebsocketMock(); + final interactions = Interactions(WebsocketInteractionBackend(client)); final entity = SlashCommand({ "id": 123, @@ -50,7 +52,9 @@ main() { 'options': [ {'type': 4, 'name': 'subOption', 'description': 'test'} ], - }, client); + 'default_member_permissions': '123', + 'dm_permission': false, + }, interactions); expect(entity.id, equals(Snowflake(123))); expect(entity.applicationId, equals(Snowflake(456))); @@ -58,8 +62,9 @@ main() { expect(entity.description, equals('testdesc')); expect(entity.type, equals(SlashCommandType.chat)); expect(entity.options, hasLength(1)); - expect(entity.defaultPermissions, isTrue); expect(entity.guild, isNull); + expect(entity.requiredPermissions, equals(123)); + expect(entity.canBeUsedInDm, isFalse); }); test('InteractionOption options not empty', () { diff --git a/test/unit/slash_command_builder.dart b/test/unit/slash_command_builder.dart index 8917533..d0026f2 100755 --- a/test/unit/slash_command_builder.dart +++ b/test/unit/slash_command_builder.dart @@ -25,17 +25,6 @@ void main() { expect(slashCommandBuilder.id, equals(Snowflake.zero())); }); - test(".addPermission", () { - final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", []); - - slashCommandBuilder - ..addPermission(RoleCommandPermissionBuilder(Snowflake.zero())) - ..addPermission(UserCommandPermissionBuilder(Snowflake.bulk())); - - expect(slashCommandBuilder.permissions, isNotNull); - expect(slashCommandBuilder.permissions, hasLength(2)); - }); - test('.registerHandler failure', () { final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", [CommandOptionBuilder(CommandOptionType.subCommand, "test", 'test')]); @@ -50,13 +39,15 @@ void main() { }); test('.build', () { - final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", []); + final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", [], requiredPermissions: PermissionsConstants.administrator); final expectedResult = { "name": "invalid-name", "description": "test", "type": SlashCommandType.chat, - "default_permission": true, + "default_permission": true, // TODO: remove when default_permission is removed + "dm_permission": true, + "default_member_permissions": PermissionsConstants.administrator.toString(), }; expect(slashCommandBuilder.build(), equals(expectedResult)); diff --git a/test/unit/utils.dart b/test/unit/utils.dart index bf2cefa..fb65e8a 100755 --- a/test/unit/utils.dart +++ b/test/unit/utils.dart @@ -1,4 +1,3 @@ -import "package:nyxx_interactions/nyxx_interactions.dart"; import 'package:nyxx_interactions/src/internal/utils.dart'; import "package:test/test.dart";