Friday, September 20, 2024 2:27:37 AM
> settings

Customize


Authenticate

> argument.rb
# frozen_string_literal: true

module ESM
  module Command
    class Argument
      #
      # Global default values for all argument
      # You may overwrite these in your argument definition
      #
      DEFAULT_TEMPLATE = {
        checked_against: :present?,
        checked_against_if: lambda do |argument, content|
          argument.required? || content.present?
        end
      }.freeze

      #
      # Global templates for any argument to use
      # These can be used by defining an argument with the same name,
      # or by providing the `:template` option during argument definition
      #
      TEMPLATES = {
        # Required: Majority of the time, this is needed.
        target: {
          checked_against: ESM::Regex::TARGET,
          description_extra: "commands.arguments.target.description_extra",
          description: "commands.arguments.target.description",
          required: true
        },

        # Required: Majority of the time, this is needed.
        command: {
          checked_against: ->(content) { ESM::Command.include?(content) },
          description: "commands.arguments.command.description",
          required: true
        },

        # Required: No functionality to "guess" this
        territory_id: {
          checked_against: ESM::Regex::TERRITORY_ID,
          description_extra: "commands.arguments.territory_id.description_extra",
          description: "commands.arguments.territory_id.description",
          required: true,
          placeholder: "territory"
        },

        # Optional in Discord: UserDefault/CommunityDefault can be used. It will be validated so it is "semi-required"
        # Required: In the bot
        community_id: {
          required: {discord: false, bot: true},
          checked_against: ESM::Regex::COMMUNITY_ID,
          description_extra: "commands.arguments.community_id.description_extra",
          description: "commands.arguments.community_id.description",
          optional_text: "commands.arguments.community_id.optional_text",
          placeholder: "community",
          modifier: lambda do |content|
            if content.present?
              # User alias
              if (id_alias = current_user.id_aliases.find_community_alias(content))
                content = id_alias.community.community_id
              end

              return content
            end

            # content == nil, attempt to find and use a default
            user_defaults = current_user.id_defaults
            return user_defaults.community.community_id if user_defaults.community_id

            # Community autofill
            return current_community.community_id if current_channel.text?

            nil
          end
        },

        # Optional in Discord: UserDefault/CommunityDefault can be used. It will be validated so it is "semi-required"
        # Required: In the bot
        server_id: {
          required: {discord: false, bot: true},
          checked_against: ESM::Regex::SERVER_ID,
          description_extra: "commands.arguments.server_id.description_extra",
          description: "commands.arguments.server_id.description",
          optional_text: "commands.arguments.server_id.optional_text",
          placeholder: "server",
          modifier: lambda do |content|
            if content.present?
              # User provided - Starts with a community ID
              return content if content.match("#{ESM::Regex::COMMUNITY_ID_OPTIONAL.source}_")

              # User alias
              if (id_alias = current_user.id_aliases.find_server_alias(content))
                return id_alias.server.server_id
              end

              # Community autofill
              if current_channel.text? && current_community.servers.by_server_id_fuzzy(content).any?
                return "#{current_community.community_id}_#{content}"
              end

              return content
            end

            # content == nil, attempt to find and use a default
            if current_channel.text?
              channel_default = current_community.id_defaults.for_channel(current_channel)
              return channel_default.server.server_id if channel_default&.server_id

              global_default = current_community.id_defaults.global
              return global_default.server.server_id if global_default&.server_id
            end

            if current_user.id_defaults.server_id
              user_defaults = current_user.id_defaults
              return user_defaults.server.server_id if user_defaults.server_id
            end

            nil
          end
        }
      }.freeze

      attr_reader :name, :type, :discord_type,
        :display_name, :command_class, :command_name,
        :default_value, :modifier, :placeholder,
        :description, :description_extra, :optional_text,
        :options, :checked_against, :checked_against_if

      #
      # A configurable representation of a command argument
      #
      # @param name [Symbol, String]
      #     The argument's name
      #
      # @param type [Symbol, String]
      #     The argument's type (directly linked to Discord).
      #     Optional.
      #     Default: :string
      #
      # @param opts [Hash]
      #     Options to configure the argument
      #
      #   @option opts [TrueClass, FalseClass, Hash] :required
      #     Controls if the argument should be required by Discord and by the Bot
      #     Fine grain control can be achieved by providing a Hash.
      #     For example, not require an argument on Discord but require it on the bot side
      #       required: {discord: false, bot: true}
      #     Optional. Default: false
      #
      #   @option opts [Symbol, String, nil] :template
      #     The name of a default entry in which `opts` are merged into.
      #     Useful for having an argument that acts like another argument, but may have different configuration
      #
      #   @option opts [String] :description
      #     This argument's description, in less than 100 characters.
      #       This description is used in Discord when viewing the argument.
      #       Note: Providing this option is optional, however, all arguments MUST have a non-blank description
      #     This value defaults to the value located at the locale path:
      #         commands.<command_name>.arguments.<argument_name>.description
      #
      #   @option opts [String] :description_extra
      #     Any extra information to be included that wouldn't fit in the 100 character limit
      #       Note: Providing this option is optional, however, this argument MUST have a non-blank description
      #       This description is used in the help documentation with the help command and on the website
      #     This value defaults to the value located at the locale path:
      #         commands.<command_name>.arguments.<argument_name>.description_extra
      #
      #   @option opts [String] :optional_text
      #     Allows for overriding the "this argument is optional" text in the help documentation.
      #       This opt is ignored if `required: true`
      #     Optional.
      #     This value defaults to the value located at the locale path:
      #         commands.<command_name>.arguments.<argument_name>.optional_text
      #
      #   @option opts [Symbol, String] :display_name
      #     Changes how the argument is displayed to the user, but not in the code
      #     Optional.
      #
      #   @option opts [Object] :default
      #     The default value if this argument. This value is ignored if `required: true`
      #     Optional.
      #     Default: nil
      #
      #   @option opts [Boolean] :preserve_case
      #     Controls if this argument's value should be converted to lowercase or not.
      #     Optional.
      #     Default: false
      #
      #   @option opts [Proc] :modifier
      #     A block of code used to modify this argument's value before validation
      #     Optional.
      #
      #   @option opts [Hash] :choices
      #     The key: display_value of choices the user can pick from
      #     Optional.
      #
      #   @option opts [Integer] :min_value
      #     If type is integer/number, this is the minimum value that can be selected
      #
      #   @option opts [Integer] :max_value
      #     If type is integer/number, this is the maximum value that can be selected
      #
      #   @option opts [Regex, String, Proc, Array] :checked_against
      #     Used to perform validation against the content provided to the argument
      #     Regex/String  - Content must `match?`
      #     Proc          - Content is passed in and can return a truthy value to consider the content as valid
      #     Array         - Content must be one of the values
      #
      #   @option opts [Proc] :checked_against_if
      #     Used to determine if the argument should be validated against :checked_against.
      #     Can return a truthy value to continue to validation. Falsey values will cause validation to be skipped
      #
      #   @option opts [String, Symbol] :placeholder
      #     Used when the command usage is displayed and use_placeholders are true. Defaults to the display name
      def initialize(name, type = nil, opts = {})
        template_name = (opts[:template] || name).to_sym

        # Precedence:
        #   opts -> template -> default template
        opts = DEFAULT_TEMPLATE.merge(TEMPLATES[template_name] || {}).merge(opts)

        @name = name
        @type = type ? type.to_sym : :string
        @discord_type = Discordrb::Interactions::OptionBuilder::TYPES[@type]
        @display_name = (opts[:display_name] || name).to_sym
        @command_class = opts[:command_class]
        @command_name = command_class.command_name.to_sym

        @default_value = opts[:default]
        @preserve_case = !!opts[:preserve_case]
        @modifier = opts[:modifier]
        @checked_against = opts[:checked_against]
        @checked_against_if = opts[:checked_against_if]
        @placeholder = opts[:placeholder].presence || name

        if opts[:required].is_a?(Hash)
          @required_by_discord = !!opts.dig(:required, :discord)
          @required_by_bot = !!opts.dig(:required, :bot)
        else
          required = !!opts[:required]
          @required_by_discord = required
          @required_by_bot = required
        end

        @options = {required: @required_by_discord}
        @options[:min_value] = opts[:min_value] if opts[:min_value]
        @options[:max_value] = opts[:max_value] if opts[:max_value]

        # I prefer {value: "Display Name"}, Discord/rb wants it to be {"Display Name": "value"}
        if opts[:choices]
          @options[:choices] = opts[:choices].map { |k, v| [v.to_s, k.to_s] }.to_h
        end

        @description = load_locale_or_provided(opts[:description], "description")
        @description_extra = load_locale_or_provided(opts[:description_extra], "description_extra").presence
        @optional_text = load_optional_text(opts[:optional_text])

        check_for_valid_configuration!
      end

      def transform_and_validate!(input, command)
        raise ArgumentError, "Invalid command argument" unless command.is_a?(ApplicationCommand)

        input_present = input.present?
        input = default_value if !input_present && default_value?

        sanitized_content =
          if input.is_a?(String) && input_present
            preserve_case? ? input.strip : input.downcase.strip
          else
            input
          end

        content =
          if modifier?
            command.instance_exec(sanitized_content, &modifier)
          else
            sanitized_content
          end

        debug!(
          argument: to_h.except(:description, :description_extra, :optional_text),
          input: input,
          before: {
            type: sanitized_content.class.name,
            content: sanitized_content
          },
          after: {
            type: content.class.name,
            content: content
          }
        )

        check_for_valid_content!(command, content)

        content
      end

      def preserve_case?
        @preserve_case
      end

      def modifier?
        !!modifier&.respond_to?(:call)
      end

      def default_value?
        !!@default_value
      end

      def required_by_bot?
        @required_by_bot
      end

      def required_by_discord?
        @required_by_discord
      end

      def required?
        required_by_bot? || required_by_discord?
      end

      def optional?
        !required_by_bot? && !required_by_discord?
      end

      def optional_text?
        optional_text.present?
      end

      def to_s
        if display_name.present?
          display_name.to_s
        else
          name.to_s
        end
      end

      def help_documentation
        output = ["**`#{self}:`**", description]
        output << "#{description_extra}." if description_extra.presence
        output << "**Note:** #{optional_text}" if optional_text?
        output.join("\n")
      end

      def to_h
        {
          name: name,
          command_name: command_name,
          display_name: display_name,
          description: description,
          description_extra: description_extra,
          optional_text: optional_text,
          default_value: default_value,
          modifier: modifier,
          checked_against: checked_against,
          preserve_case: preserve_case?,
          discord: @options,
          bot: {
            required: @required_by_bot
          }
        }
      end

      private

      def load_locale_or_provided(path, suffix)
        return "" if path == "" # Allows skipping this process. Validation will still fire

        locale_path = path.presence || "commands.#{command_name}.arguments.#{name}.#{suffix}"
        localized = I18n.translate(locale_path, default: "")

        # Allows not having to provide an I18n path as the description
        if (localized.blank? || localized.starts_with?("translation missing")) && path.present?
          path
        else
          localized
        end
      end

      def load_optional_text(text_or_path)
        return "" unless optional?

        if (text = load_locale_or_provided(text_or_path, "optional_text"))
          return text if text.present?
        end

        text = "This argument is optional#{default_value? ? "" : "."}"
        text += " and it defaults to `#{default_value}`." if default_value?
        text
      end

      def check_for_valid_content!(command, content)
        return if checked_against_if.nil? || checked_against.nil?
        return unless successful_checked_against_if?(command, content)
        return if successful_checked_against?(command, content)

        raise ESM::Exception::InvalidArgument, self
      end

      def check_for_valid_configuration!
        # choices must be hash
        # choice values must be string
        if options.key?(:choices)
          if !options[:choices].is_a?(Hash)
            raise ArgumentError, "#{command_class}:argument.#{name} - choices must be a hash"
          end

          if options[:choices].values.any? { |v| !v.is_a?(String) }
            raise ArgumentError, "#{command_class}:argument.#{name} - choices cannot contain non-string values"
          end
        end

        # min/max values can only be with integer/number type
        if options.key?(:min_value) || options.key?(:max_value)
          if [:integer, :number].exclude?(type)
            raise ArgumentError, "#{command_class}:argument.#{name} - min/max values can only be used with integer or number types"
          end
        end

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

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

      def successful_checked_against_if?(command, content)
        case checked_against_if
        when Proc
          !!command.instance_exec(self, content, &checked_against_if)
        when TrueClass, FalseClass
          checked_against_if
        end
      end

      def successful_checked_against?(command, content)
        case checked_against
        when Regexp, String
          content.to_s.match?(checked_against)
        when Proc
          !!command.instance_exec(content, &checked_against)
        when Array
          checked_against.include?(content)
        when Symbol
          !!content.public_send(checked_against)
        end
      end
    end
  end
end
All opinions represented herein are my own
- © 2024 itsthedevman
- build 340fbb8