Friday, September 20, 2024 4:31:08 AM
> settings

Customize


Authenticate

> definition.rb
# frozen_string_literal: true

module ESM
  module Command
    class Base
      # Methods involved with defining or creating a Command for ESM
      module Definition
        extend ActiveSupport::Concern

        class Attribute < Struct.new(:modifiable, :default)
          attr_predicate :modifiable

          def initialize(modifiable: true, default: nil)
            super
          end

          def default?
            return false if default.nil?

            !!default
          end
        end

        included do
          class_attribute :abstract_class
          class_attribute :arguments
          class_attribute :attributes
          class_attribute :category
          class_attribute :command_name
          class_attribute :description
          class_attribute :description_extra
          class_attribute :examples_raw
          class_attribute :limited_to
          class_attribute :namespace
          class_attribute :requirements
          class_attribute :skipped_actions
          class_attribute :type
        end

        class_methods do
          #
          # Defines an argument for this command
          #
          # @param name [Symbol] The name of the argument
          # @param type [Symbol] The Discord argument type (defaults to string in Argument)
          # @param **opts [Hash] See ESM::Command::Argument#initialize
          #
          def argument(name, type = nil, **opts)
            arguments[name] = Argument.new(name, type, opts.merge(command_class: self))
            self
          end

          #
          # Sets the command's type. Commonly :admin, :player, or :development
          #
          # @param type [Symbol, String]
          #
          def command_type(type)
            self.type = type.to_sym
            self
          end

          #
          # Limits the command to a particular channel type.
          #
          # @param channel_type [Symbol, String] Valid options: :text, :pm
          #
          def limit_to(channel_type)
            if TEXT_CHANNEL_TYPES.include?(channel_type)
              self.limited_to = :text
            elsif DM_CHANNEL_TYPES.include?(channel_type)
              self.limited_to = :dm
            else
              raise ArgumentError,
                "Invalid channel type for #{self.class.name}.limit_to(:#{channel_type}). Expected one of: #{CHANNEL_TYPES}"
            end

            self
          end

          #
          # Changes an attribute on the command. These are used mainly for permissions
          #
          # @param attribute [Symbol, String] The name of the attribute
          # @param **opts [Hash] Configuration options. See ESM::Command::Attribute
          #
          def change_attribute(attribute, **opts)
            if !attributes.key?(attribute)
              raise ArgumentError, "Invalid attribute provided: #{attribute}. Expected one of: #{attributes.keys}"
            end

            attribute = attributes[attribute]
            opts.each { |k, v| attribute.send(:"#{k}=", v) }
            self
          end

          #
          # Sets a list of requirements for the command
          #
          # @param *keys [Symbol] The requirements. Valid options: :registration, :dev
          #
          def requires(*)
            requirements.set(*)
            self
          end

          def does_not_require(*)
            requirements.unset(*)
            self
          end

          #
          # A list of check names that should be skipped during the lifecycle
          #
          # @param *actions [Array<Symbol>] The name of the actions to skip
          #
          def skip_action(*)
            skipped_actions.set(*)
            self
          end

          alias_method :skip_actions, :skip_action

          #
          # Sets the command's namespace
          # When registered with Discord, each namespace is converted to: /<segments...> <command_name>
          #
          # @param *segments [Array<Symbol>] The individual segments
          # @param command_name [Symbol] The command name. Defaults to the result from `#command_name`
          #
          def command_namespace(*segments, command_name: self.command_name)
            raise ESM::Exception::InvalidCommandNamespace, "#{name}#command_namespace - Discord only supports one subgroup per command" if segments.size > 2

            self.namespace = {
              segments: segments.presence || [],
              command_name: command_name.to_sym
            }

            self
          end

          #
          # Adds the root namespace (/<command_name>) to this command
          #
          alias_method :use_root_namespace, :command_namespace

          #
          # Returns a hash representation of this command
          #
          # @return [Hash]
          #
          def to_h
            {
              name: command_name,
              type: type,
              category: category,
              namespace: namespace,
              limited_to: limited_to,
              attributes: attributes,
              requirements: requirements.to_h,
              skipped_actions: skipped_actions.to_h,
              arguments: arguments,
              description: description,
              description_extra: description_extra,
              examples: examples_raw
            }
          end

          def to_details
            details = to_h.except(:skipped_actions, :namespace)

            details[:usage] = usage(with_args: false)

            details[:arguments] = details[:arguments].each_with_object({}) do |(name, argument), hash|
              hash[argument.display_name] = argument
            end

            details[:description] = details[:description].then do |description|
              if (description_extra = details.delete(:description_extra).presence)
                description += "\n#{description_extra}"
              end

              description
            end

            details.transform_keys { |k| "command_#{k}" }
          end

          #
          # Returns the JSON representation of this command
          #
          # @return [String]
          #
          def to_json(...)
            to_h.to_json(...)
          end

          #
          # Returns the command's examples as a string, by default
          #
          # @param raw [TrueClass, FalseClass] True for the raw hash, false for a string. Default: false
          #
          def examples(raw: false)
            return examples_raw if raw

            examples_raw.map_join("\n") do |example|
              <<~STRING
                ```
                #{usage(with_args: true, arguments: example[:arguments] || {})}
                ```#{example[:description]}
              STRING
            end
          end

          # @!visibility private
          def inherited(child_class)
            child_class.__disconnect_variables!
            super
          end

          # @!visibility private
          def __disconnect_variables!
            self.abstract_class = false
            self.arguments = {}

            self.attributes = {
              enabled: Attribute.new(default: true),
              allowlist_enabled: Attribute.new(default: false),
              allowlisted_role_ids: Attribute.new(default: []),
              allowed_in_text_channels: Attribute.new(default: true),
              cooldown_time: Attribute.new(default: 2.seconds)
            }

            # ESM::Command::Territory::SetId => set_id
            self.command_name = name.demodulize.underscore.downcase

            # ESM::Command::Request::Accept => system
            self.category = module_parent.name.demodulize.downcase

            command_namespace(category.to_sym) # Sets the default namespace to be: /<category> <command_name>

            self.description = I18n.t("commands.#{command_name}.description", default: "")
            self.description_extra = I18n.t("commands.#{command_name}.description_extra", default: nil)
            self.examples_raw = I18n.t("commands.#{command_name}.examples", default: [])

            self.limited_to = nil
            self.type = :player
            self.requirements = Inquirer.new(:dev, :registration)

            self.skipped_actions = Inquirer.new(
              :connected_server, :cooldown, :nil_target_user,
              :nil_target_server, :nil_target_community, :different_community
            )

            # Require registration by default
            requires :registration
          end

          # @!visibility private
          def register_root_command(community_discord_id, command_name)
            check_for_valid_configuration!

            ::ESM.bot.register_application_command(
              command_name,
              description,
              server_id: community_discord_id
            ) do |builder, _permission_builder|
              register_arguments(builder)
            end
          end

          # @!visibility private
          def register_subcommand(builder, command_name)
            check_for_valid_configuration!

            builder.subcommand(command_name.to_sym, description, &method(:register_arguments))
          end

          # @!visibility private
          def register_arguments(builder)
            # Required arguments must be first (Discord requirement)
            sorted_arguments = arguments.values.sort_by { |argument| argument.required_by_discord? ? 0 : 1 }

            sorted_arguments.each do |argument|
              if !builder.respond_to?(argument.type)
                raise ESM::Exception::InvalidCommandArgument, "Invalid type provided for argument #{argument.to_h}"
              end

              info!(
                command: usage(with_args: false),
                argument: {name: argument.name, type: argument.type}
              )

              builder.public_send(
                argument.type,
                argument.display_name,
                argument.description,
                **argument.options
              )
            end

            info!(command: usage(with_args: false), status: :registered)
          end

          # @!visibility private
          def check_for_valid_configuration!
            return if abstract_class

            if description.length > 100
              raise ArgumentError, "#{name} - description cannot be longer than 100 characters"
            end

            if description.length < 1
              raise ArgumentError, "#{name} - description must be at least 1 character long"
            end
          end
        end

        ############################################################
        ############################################################

        attr_reader :response # v1
        attr_reader :name, :category, :cooldown_time, :permissions, :timers, :event

        attr_accessor :current_community, :current_user, :current_channel

        delegate :examples, to: "self.class"

        def initialize(user: nil, server: nil, channel: nil, arguments: {})
          command_class = self.class

          # This can be modified on the fly. Disconnect it from the class
          self.skipped_actions = skipped_actions.dup

          @name = command_class.command_name
          @category = command_class.category
          @attributes = attributes.to_istruct

          @current_user = ESM::User.from_discord(user)
          @current_community = ESM::Community.from_discord(server)
          @current_channel = channel
          @arguments = ESM::Command::Arguments.new(
            self,
            templates: command_class.arguments,
            values: arguments
          )

          @timers = Timers.new(name)

          debug!(
            command_name: name,
            user: user&.attributes,
            server: server&.attributes,
            channel: channel&.attributes,
            arguments: self.arguments
          )
        end
      end
    end
  end
end
All opinions represented herein are my own
- © 2024 itsthedevman
- build 340fbb8