diff --git a/CHANGELOG.md b/CHANGELOG.md index 4363a45..ba249ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 4.2.0-dev.0 +__Deprecations__: +- Deprecated `AbstractCheck.permissions` and all associated features. + +__New features__: +- Added `AbtractCheck.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. + +__Miscellaneous__: +- Bump `nyxx_interactions` to 4.2.0. +- Added proper names to context type checks if none is provided. + ## 4.1.2 __Bug fixes__: - Fixes an issue where slash commands nested within text-only commands would not be registered diff --git a/lib/nyxx_commands.dart b/lib/nyxx_commands.dart index 15ad134..8a925f7 100644 --- a/lib/nyxx_commands.dart +++ b/lib/nyxx_commands.dart @@ -1,7 +1,7 @@ /// A framework for easily creating slash commands and text commands for Discord using the nyxx library. library nyxx_commands; -export 'src/checks/checks.dart' show AbstractCheck, Check, GuildCheck, RoleCheck, UserCheck; +export 'src/checks/checks.dart' show AbstractCheck, Check; export 'src/checks/context_type.dart' show ChatCommandCheck, @@ -11,6 +11,9 @@ export 'src/checks/context_type.dart' MessageCommandCheck, UserCommandCheck; export 'src/checks/cooldown.dart' show CooldownCheck, CooldownType; +export 'src/checks/guild.dart' show GuildCheck; +export 'src/checks/permissions.dart' show PermissionsCheck; +export 'src/checks/user.dart' show RoleCheck, UserCheck; export 'src/commands.dart' show CommandsPlugin; export 'src/commands/chat_command.dart' show ChatCommand, ChatGroup, CommandType; export 'src/commands/interfaces.dart' diff --git a/lib/src/checks/checks.dart b/lib/src/checks/checks.dart index 76c809d..f490154 100644 --- a/lib/src/checks/checks.dart +++ b/lib/src/checks/checks.dart @@ -70,7 +70,31 @@ abstract class AbstractCheck { /// to a given role; /// - [CommandPermissionBuilderAbstract.user], for creating slash command permissions that apply /// to a given user. - Future> get permissions; + @Deprecated('Use allowsDm and requiredPermissions instead') + final Future> permissions = Future.value([]); + + /// Whether this check will allow commands to be executed in DM channels. + /// + /// If this is `false`, users will be unable to execute slash commands in DM channels with the + /// bot. However, users might still execute a text [ChatCommand] in DMs, so further validation in + /// the check itself is required. + /// + /// You might also be interested in: + /// - [requiredPermissions], for fine-tuning how commands can be executed in a guild. + FutureOr get allowsDm; + + /// The permissions required from members to pass this check. + /// + /// If this is `null` (or `Future`), all members will be allowed to execute the command. + /// + /// Members that do not have at least one of these permissions will see the command as unavailable + /// in their Discord client. However, users might still execute a text [ChatCommand], so further + /// validation in the check itself is required. + /// + /// 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; /// An iterable of callbacks executed before a command is executed but after all the checks for /// that command have succeeded. @@ -129,9 +153,7 @@ abstract class AbstractCheck { /// Since some checks are so common, nyxx_commands provides a set of in-built checks that also /// integrate with the [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) /// API: -/// - [GuildCheck], for checking if a command was invoked in a specific guild; -/// - [RoleCheck], for checking if a command was invoked by a member with a specific role; -/// - [UserCheck], for checking if a command was invoked by a specific user. +/// - [GuildCheck], for checking if a command was invoked in a specific guild. /// /// You might also be interested in: /// - [Check.any], [Check.deny] and [Check.all], for modifying the behaviour of checks; @@ -139,13 +161,25 @@ abstract class AbstractCheck { class Check extends AbstractCheck { final FutureOr Function(IContext) _check; + @override + final FutureOr allowsDm; + + @override + final FutureOr requiredPermissions; + /// Create a new [Check]. /// /// [_check] should be a callback that returns `true` or `false` to indicate check success or /// 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. - Check(this._check, [String name = 'Check']) : super(name); + // TODO: Use named parameters instead of positional parameters + Check( + this._check, [ + String name = 'Check', + this.allowsDm = true, + this.requiredPermissions, + ]) : super(name); /// Creates a check that succeeds if any of [checks] succeeds. /// @@ -206,9 +240,6 @@ class Check extends AbstractCheck { @override FutureOr check(IContext context) => _check(context); - @override - Future> get permissions => Future.value([]); - @override Iterable get postCallHooks => []; @@ -244,24 +275,6 @@ class _AnyCheck extends AbstractCheck { return false; } - @override - Future> get permissions async { - Iterable> permissions = - await Future.wait(checks.map((check) => check.permissions)); - - return permissions.first.where( - (permission) => - permission.hasPermission || - // If permission is not granted, we check that it is not allowed by any of the other - // checks. If every check denies the permission for this id, also deny the permission in - // the combined version. - permissions.every((element) => element.any( - // CommandPermissionBuilderAbstract does not override == so we manually check it - (p) => p.id == permission.id && !p.hasPermission, - )), - ); - } - @override Iterable get preCallHooks => [ (context) { @@ -293,31 +306,41 @@ class _AnyCheck extends AbstractCheck { } } ]; -} -class _DenyCheck extends Check { - final AbstractCheck source; + @override + Future get allowsDm async { + for (final check in checks) { + if (await check.allowsDm) { + return true; + } + } - _DenyCheck(this.source, [String? name]) - : super((context) async => !(await source.check(context)), name ?? 'Denied ${source.name}'); + return false; + } @override - Future> get permissions async { - Iterable permissions = await source.permissions; + Future get requiredPermissions async { + int result = 0; - Iterable rolePermissions = - permissions.whereType(); + for (final check in checks) { + int? permissions = await check.requiredPermissions; + + if (permissions == null) { + return null; + } - Iterable userPermissions = - permissions.whereType(); + result |= permissions; + } - return [ - ...rolePermissions.map((permission) => CommandPermissionBuilderAbstract.role(permission.id, - hasPermission: !permission.hasPermission)), - ...userPermissions - .map((e) => CommandPermissionBuilderAbstract.user(e.id, hasPermission: !e.hasPermission)), - ]; + return result; } +} + +class _DenyCheck extends Check { + final AbstractCheck source; + + _DenyCheck(this.source, [String? name]) + : super((context) async => !(await source.check(context)), name ?? 'Denied ${source.name}'); // 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 @@ -327,6 +350,20 @@ class _DenyCheck extends Check { @override Iterable get postCallHooks => source.postCallHooks; + + @override + FutureOr get allowsDm async => !await source.allowsDm; + + @override + FutureOr get requiredPermissions async { + int? permissions = await source.requiredPermissions; + + if (permissions == null) { + return null; + } + + return ~permissions & PermissionsConstants.allPermissions; + } } class _GroupCheck extends Check { @@ -342,14 +379,6 @@ class _GroupCheck extends Check { return !syncResults.contains(false) && !(await Future.wait(asyncResults)).contains(false); }, name ?? 'All of [${checks.map((e) => e.name).join(', ')}]'); - @override - Future> get permissions async => - (await Future.wait(checks.map( - (e) => e.permissions, - ))) - .fold([], - (acc, element) => (acc as List)..addAll(element)); - @override Iterable get preCallHooks => checks.map((e) => e.preCallHooks).expand((_) => _); @@ -357,164 +386,32 @@ class _GroupCheck extends Check { @override Iterable get postCallHooks => checks.map((e) => e.postCallHooks).expand((_) => _); -} - -/// A check that checks that the user that executes a command has a specific role. -/// -/// This check integrates with the [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) -/// API, so users that cannot use a command because of this check will have that command appear -/// grayed out in their Discord client. -class RoleCheck extends Check { - /// The IDs of the roles this check allows. - Iterable roleIds; - - /// 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.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); - - /// 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]) - : roleIds = [id], - super( - (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); - - /// 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]) - : roleIds = roles, - super( - (context) => context.member?.roles.any((role) => roles.contains(role.id)) ?? false, - name ?? 'Role Check on any of [${roles.join(', ')}]', - ); @override - Future> get permissions => Future.value([ - CommandPermissionBuilderAbstract.role(Snowflake.zero(), hasPermission: false), - ...roleIds.map((e) => CommandPermissionBuilderAbstract.role(e, hasPermission: true)), - ]); -} - -/// A check that checks that a command was executed by a specific user. -/// -/// This check integrates with the [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) -/// API, so users that cannot use a command because of this check will have that command appear -/// grayed out in their Discord client. -class UserCheck extends Check { - /// The IDs of the users this check allows. - Iterable userIds; - - /// 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.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); - - /// Create a new [UserCheck] that succeeds if the ID of the user that created the context is [id]. - UserCheck.id(Snowflake id, [String? name]) - : userIds = [id], - super((context) => context.user.id == id, name ?? 'User Check on $id'); + FutureOr get allowsDm async { + for (final check in checks) { + if (!await check.allowsDm) { + return false; + } + } - /// 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); - - /// 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]) - : userIds = ids, - super( - (context) => ids.contains(context.user.id), - name ?? 'User Check on any of [${ids.join(', ')}]', - ); + return true; + } @override - Future> get permissions => Future.value([ - CommandPermissionBuilderAbstract.user(Snowflake.zero(), hasPermission: false), - ...userIds.map((e) => CommandPermissionBuilderAbstract.user(e, hasPermission: true)), - ]); -} - -/// A check that checks that a command was executed in a particular guild, or in a channel that is -/// not in a guild. -/// -/// This check is special as commands with this check will only be registered as slash commands in -/// the guilds specified by this guild check. For this functionality to work, however, this check -/// must be a "top-level" check - that is, a check that is not nested within a modifier such as -/// [Check.any], [Check.deny] or [Check.all]. -/// -/// The value of this check overrides [CommandsPlugin.guild]. -/// -/// You might also be interested in: -/// - [CommandsPlugin.guild], for globally setting a guild to register slash commands to. -class GuildCheck extends Check { - /// The IDs of the guilds that this check allows. - /// - /// If [guildIds] is `[null]`, then any guild is allowed, but not channels outside of guilds/ - Iterable guildIds; + FutureOr get requiredPermissions async { + Iterable permissions = checks.whereType(); - /// 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.any], for checking if the context originated in any of a set of guilds. - GuildCheck(IGuild guild, [String? name]) : this.id(guild.id, name); + if (permissions.isEmpty) { + return null; + } - /// Create a [GuildCheck] that succeeds if the ID of the guild the context originated in is [id]. - GuildCheck.id(Snowflake id, [String? name]) - : guildIds = [id], - super((context) => context.guild?.id == id, name ?? 'Guild Check on $id'); + int result = PermissionsConstants.allPermissions; - /// 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]) - : guildIds = [], - super((context) => context.guild == null, name ?? 'Guild Check on '); + for (final permission in permissions) { + result &= permission; + } - /// 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]) - : guildIds = [null], - super( - (context) => context.guild != null, - name ?? 'Guild Check on ', - ); - - /// 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); - - /// Create a [GuildCheck] that succeeds if the id of the guild the context originated in is in - /// [ids]. - GuildCheck.anyId(Iterable ids, [String? name]) - : guildIds = ids, - super( - (context) => ids.contains(context.guild?.id), - name ?? 'Guild Check on any of [${ids.join(', ')}]', - ); + return result; + } } diff --git a/lib/src/checks/context_type.dart b/lib/src/checks/context_type.dart index aa66a68..94dcd40 100644 --- a/lib/src/checks/context_type.dart +++ b/lib/src/checks/context_type.dart @@ -1,8 +1,3 @@ -import 'dart:async'; - -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; - import '../context/interaction_context.dart'; import '../context/chat_context.dart'; import '../context/message_context.dart'; @@ -23,7 +18,11 @@ import 'checks.dart'; /// - [ChatCommandCheck], for checking that the command being invoked is a [ChatCommand]. class InteractionCommandCheck extends Check { /// Create a new [InteractionChatCommandCheck]. - InteractionCommandCheck() : super((context) => context is IInteractionContext); + InteractionCommandCheck([String? name]) + : super( + (context) => context is IInteractionContext, + name ?? 'Interaction check', + ); } /// A check that succeeds if the command being invoked is a [MessageCommand]. @@ -35,7 +34,11 @@ class InteractionCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class MessageCommandCheck extends Check { /// Create a new [MessageCommandCheck]. - MessageCommandCheck() : super((context) => context is MessageContext); + MessageCommandCheck([String? name]) + : super( + (context) => context is MessageContext, + name ?? 'Message command check', + ); } /// A check that succeeds if the command being invoked is a [UserCommand]. @@ -47,7 +50,11 @@ class MessageCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class UserCommandCheck extends Check { /// Create a new [UserCommandCheck]. - UserCommandCheck() : super((context) => context is UserContext); + UserCommandCheck([String? name]) + : super( + (context) => context is UserContext, + name ?? 'User command check', + ); } /// A check that succeeds if the command being invoked is a [ChatCommand]. @@ -65,7 +72,11 @@ class UserCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class ChatCommandCheck extends Check { /// Create a new [ChatCommandCheck]. - ChatCommandCheck() : super((context) => context is IChatContext); + ChatCommandCheck([String? name]) + : super( + (context) => context is IChatContext, + name ?? 'Chat command check', + ); } /// A check that succeeds if the command being invoked is a [ChatCommand] and that the context was @@ -82,7 +93,11 @@ class ChatCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class InteractionChatCommandCheck extends Check { /// Create a new [InteractionChatCommandCheck]. - InteractionChatCommandCheck() : super((context) => context is InteractionChatContext); + InteractionChatCommandCheck([String? name]) + : super( + (context) => context is InteractionChatContext, + name ?? 'Interaction chat command check', + ); } /// A check that succeeds if the command being invoked is a [ChatCommand] and that the context was @@ -98,10 +113,12 @@ class InteractionChatCommandCheck extends Check { /// - [ChatCommandCheck], for checking that the command being exected is a [ChatCommand]. class MessageChatCommandCheck extends Check { /// Create a new [MessageChatCommandCheck]. - MessageChatCommandCheck() : super((context) => context is MessageChatContext); - - @override - Future> get permissions => Future.value([ - CommandPermissionBuilderAbstract.role(Snowflake.zero(), hasPermission: false), - ]); + MessageChatCommandCheck([String? name]) + : super( + (context) => context is MessageChatContext, + name ?? 'Message chat command check', + // Disallow command in both guilds and DMs (0 = disable for all members). + false, + 0, + ); } diff --git a/lib/src/checks/cooldown.dart b/lib/src/checks/cooldown.dart index 2789f45..63d4f42 100644 --- a/lib/src/checks/cooldown.dart +++ b/lib/src/checks/cooldown.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; import '../context/context.dart'; import 'checks.dart'; @@ -307,8 +306,11 @@ class CooldownCheck extends AbstractCheck { ]; @override - Future> get permissions => Future.value([]); + Iterable get postCallHooks => []; @override - Iterable get postCallHooks => []; + bool get allowsDm => true; + + @override + int? get requiredPermissions => null; } diff --git a/lib/src/checks/guild.dart b/lib/src/checks/guild.dart new file mode 100644 index 0000000..f6ba7f5 --- /dev/null +++ b/lib/src/checks/guild.dart @@ -0,0 +1,90 @@ +// 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'; + +/// A check that checks that a command was executed in a particular guild, or in a channel that is +/// not in a guild. +/// +/// This check is special as commands with this check will only be registered as slash commands in +/// the guilds specified by this guild check. For this functionality to work, however, this check +/// must be a "top-level" check - that is, a check that is not nested within a modifier such as +/// [Check.any], [Check.deny] or [Check.all]. +/// +/// The value of this check overrides [CommandsPlugin.guild]. +/// +/// This check integrates with the [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) +/// API, so users that cannot use a command because of this check will have that command appear +/// unavailable out in their Discord client. +/// +/// You might also be interested in: +/// - [CommandsPlugin.guild], for globally setting a guild to register slash commands to. +class GuildCheck extends Check { + /// The IDs of the guilds that this check allows. + /// + /// If [guildIds] is `[null]`, then any guild is allowed, but not channels outside of guilds. + Iterable guildIds; + + /// 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.any], for checking if the context originated in any of a set of guilds. + GuildCheck(IGuild guild, [String? name]) : this.id(guild.id, name); + + /// Create a [GuildCheck] that succeeds if the ID of the guild the context originated in is [id]. + GuildCheck.id(Snowflake id, [String? name]) + : guildIds = [id], + super((context) => context.guild?.id == id, name ?? 'Guild Check on $id', 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]) + : guildIds = [], + super((context) => context.guild == null, name ?? 'Guild Check on ', true, 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]) + : guildIds = [null], + super( + (context) => context.guild != null, + name ?? 'Guild Check on ', + 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); + + /// Create a [GuildCheck] that succeeds if the id of the guild the context originated in is in + /// [ids]. + GuildCheck.anyId(Iterable ids, [String? name]) + : guildIds = ids, + super( + (context) => ids.contains(context.guild?.id), + name ?? 'Guild Check on any of [${ids.join(', ')}]', + false, + ); +} diff --git a/lib/src/checks/permissions.dart b/lib/src/checks/permissions.dart new file mode 100644 index 0000000..186bf14 --- /dev/null +++ b/lib/src/checks/permissions.dart @@ -0,0 +1,164 @@ +// 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 'checks.dart'; + +/// A check that succeeds if the member invoking the command has a certain set of permissions. +/// +/// You might also be interested in: +/// - [UserCheck], for checking if a command was executed by a specific user; +/// - [RoleCheck], for checking if a command was executed by a user with a specific role. +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; + /// - [AbstractCheck.requiredPermissions], for setting permissions on any check. + // TODO: Rename to `permissions` once AbstractCheck.permissions is removed + final int permissionsValue; + + /// 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]. + /// + /// 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. + final bool requiresAll; + + /// Create a new [PermissionsCheck]. + PermissionsCheck( + this.permissionsValue, { + this.allowsOverrides = true, + this.requiresAll = false, + String? name, + bool allowsDm = true, + }) : super( + (context) async { + IMember? member = context.member; + + if (member == null) { + return allowsDm; + } + + IPermissions effectivePermissions = + await (context.channel as IGuildChannel).effectivePermissions(member); + + if (allowsOverrides) { + ISlashCommand command; + + if (context is IInteractionContext) { + command = context.interactionEvent.interactions.commands + .firstWhere((command) => command.id == context.interaction.commandId); + } 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; + + while (root.parent is ICommandRegisterable) { + root = root.parent as ICommandRegisterable; + } + + Iterable matchingCommands = + context.commands.interactions.commands.where( + (command) => command.name == root.name && command.type == SlashCommandType.chat, + ); + + if (matchingCommands.isEmpty) { + return false; + } + + command = matchingCommands.first; + } + + ISlashCommandPermissionOverrides overrides = + await command.getPermissionOverridesInGuild(context.guild!.id).getOrDownload(); + + bool? def; + bool? channelDef; + bool? role; + bool? channel; + bool? user; + + 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 && + override.id == context.channel.id) { + channel = override.allowed; + } else if (override.type == SlashCommandPermissionType.role) { + int roleIndex = -1; + + int i = 0; + for (final role in member.roles) { + if (role.id == override.id) { + roleIndex = i; + break; + } + + i++; + } + + if (highestRoleIndex < roleIndex) { + role = override.allowed; + highestRoleIndex = roleIndex; + } + } else if (override.type == SlashCommandPermissionType.user && + override.id == context.user.id) { + user = override.allowed; + // No need to continue if we found an override for the specific user + break; + } + } + + Iterable prioritised = [def, channelDef, role, channel, user].whereType(); + + if (prioritised.isNotEmpty) { + return prioritised.last; + } + } + + int corresponding = effectivePermissions.raw & permissionsValue; + + if (requiresAll) { + return corresponding == permissionsValue; + } + + return corresponding != 0; + }, + name ?? 'Permissions check on $permissionsValue', + allowsDm, + permissionsValue, + ); + + /// Create a [PermissionsCheck] that allows nobody to execute a command, unless configured + /// otherwise by a permission override. + PermissionsCheck.nobody({ + bool allowsOverrides = true, + String? name, + bool allowsDm = true, + }) : this(0, allowsOverrides: allowsOverrides, allowsDm: allowsDm, name: name); +} diff --git a/lib/src/checks/user.dart b/lib/src/checks/user.dart new file mode 100644 index 0000000..9a7e3d4 --- /dev/null +++ b/lib/src/checks/user.dart @@ -0,0 +1,90 @@ +// 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'; + +/// A check that checks that a command was executed by a specific user. +class UserCheck extends Check { + /// The IDs of the users this check allows. + Iterable userIds; + + /// 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.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); + + /// Create a new [UserCheck] that succeeds if the ID of the user that created the context is [id]. + UserCheck.id(Snowflake id, [String? name]) + : userIds = [id], + super((context) => context.user.id == id, name ?? 'User Check on $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); + + /// 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]) + : userIds = ids, + super( + (context) => ids.contains(context.user.id), + name ?? 'User Check on any of [${ids.join(', ')}]', + ); +} + +/// A check that checks that the user that executes a command has a specific role. +class RoleCheck extends Check { + /// The IDs of the roles this check allows. + Iterable roleIds; + + /// 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.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); + + /// 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]) + : roleIds = [id], + super( + (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); + + /// 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]) + : roleIds = roles, + super( + (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 ec444b5..cc2aa65 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -20,6 +20,7 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; import 'checks/checks.dart'; +import 'checks/guild.dart'; import 'commands/chat_command.dart'; import 'commands/interfaces.dart'; import 'commands/message_command.dart'; @@ -508,28 +509,12 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { Future> _getSlashBuilders() async { List builders = []; - const Snowflake zeroSnowflake = Snowflake.zero(); - for (final command in children) { - if (!_shouldGnerateBuildersFor(command)) { - continue; - } - - Iterable permissions = await _getPermissions(command); - - if (permissions.length == 1 && - permissions.first.id == zeroSnowflake && - !permissions.first.hasPermission) { + if (!_shouldGenerateBuildersFor(command)) { continue; } - bool defaultPermission = true; - for (final permission in permissions) { - if (permission.id == zeroSnowflake) { - defaultPermission = permission.hasPermission; - break; - } - } + AbstractCheck allChecks = Check.all(command.checks); Iterable guildChecks = command.checks.whereType(); @@ -547,10 +532,8 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { List.of( _processHandlerRegistration(command.getOptions(this), command), ), - defaultPermissions: defaultPermission, - permissions: List.of( - permissions.where((permission) => permission.id != zeroSnowflake), - ), + canBeUsedInDm: await allChecks.allowsDm, + requiredPermissions: await allChecks.requiredPermissions, guild: guildId ?? guild, type: SlashCommandType.chat, ); @@ -567,10 +550,8 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { command.name, null, [], - defaultPermissions: defaultPermission, - permissions: List.of( - permissions.where((permission) => permission.id != zeroSnowflake), - ), + canBeUsedInDm: await allChecks.allowsDm, + requiredPermissions: await allChecks.requiredPermissions, guild: guildId ?? guild, type: SlashCommandType.user, ); @@ -583,10 +564,8 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { command.name, null, [], - defaultPermissions: defaultPermission, - permissions: List.of( - permissions.where((permission) => permission.id != zeroSnowflake), - ), + canBeUsedInDm: await allChecks.allowsDm, + requiredPermissions: await allChecks.requiredPermissions, guild: guildId ?? guild, type: SlashCommandType.message, ); @@ -602,7 +581,7 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { return builders; } - bool _shouldGnerateBuildersFor(ICommandRegisterable child) { + bool _shouldGenerateBuildersFor(ICommandRegisterable child) { if (child is IChatCommandComponent) { if (child.hasSlashCommand) { return true; @@ -614,40 +593,6 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { return true; } - Future> _getPermissions(IChecked command) async { - Map uniquePermissions = {}; - - for (final check in command.checks) { - Iterable checkPermissions = await check.permissions; - - for (final permission in checkPermissions) { - if (uniquePermissions.containsKey(permission.id) && - uniquePermissions[permission.id]!.hasPermission != permission.hasPermission) { - logger.warning( - 'Check "${check.name}" is in conflict with a previous check on ' - 'permissions for ' - '${permission.id.id == 0 ? 'the default permission' : 'id ${permission.id}'}. ' - 'Permission has been set to false to prevent unintended usage.', - ); - - if (permission is RoleCommandPermissionBuilder) { - uniquePermissions[permission.id] = - CommandPermissionBuilderAbstract.role(permission.id, hasPermission: false); - } else { - uniquePermissions[permission.id] = - CommandPermissionBuilderAbstract.user(permission.id, hasPermission: false); - } - - continue; - } - - uniquePermissions[permission.id] = permission; - } - } - - return uniquePermissions.values; - } - Iterable _processHandlerRegistration( Iterable options, IChatCommandComponent current, diff --git a/pubspec.yaml b/pubspec.yaml index cc640c2..e99493f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_commands -version: 4.1.2 +version: 4.2.0-dev.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 @@ -13,7 +13,7 @@ dependencies: logging: ^1.0.2 meta: ^1.7.0 nyxx: ^3.0.0 - nyxx_interactions: ^4.1.0 + nyxx_interactions: ^4.2.0 random_string: ^2.3.1 dev_dependencies: