# 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,
: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
@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#{"." unless 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