# frozen_string_literal: true
module ESM
module Command
class Base
module Helpers
extend ActiveSupport::Concern
class_methods do
#
# Returns the command's execution string, with or without arguments.
# /command subcommand argument_1:value argument_2: value
#
# @param overrides [Hash] Argument names and values to set.
# These will override the default arguments. Ignored if with_args is false
#
# @param use_placeholders [true/false] Controls if a placeholder is used as the arguments value
# If true, and the argument is blank, the argument's name will be used as a placeholder
# If false, and the argument is blank, the argument is omitted from the result
#
# @param with_args [true/false] Should the arguments be included in result?
# @param with_slash [true/false] Should the result start with a slash?
# @param skip_defaults [true/false] Skip displaying arguments that are using their default value?
#
# @return [String]
#
def usage(arguments: {}, use_placeholders: true, with_args: true, with_slash: true, skip_defaults: true)
command_statement = namespace[:segments].dup
command_statement << namespace[:command_name]
if with_args && self.arguments.size > 0
self.arguments.each do |(name, template)|
# Better support for falsey values
value =
if arguments.key?(name)
arguments[name]
elsif arguments.key?(template.display_name)
arguments[template.display_name]
end
# Perf
value_is_blank = value.blank?
next if value_is_blank && template.optional?
next if value_is_blank && !use_placeholders
next if skip_defaults && template.default_value? && template.default_value == value
command_statement << (value_is_blank ? "#{template}:<#{template.placeholder}>" : "#{template}:#{value}")
end
end
command_statement = command_statement.join(" ")
command_statement.prepend("/") if with_slash
command_statement
end
end
#
# See class method .usage above
#
def usage(**args)
args[:use_placeholders] ||= false
args[:arguments] = arguments.merge(args[:arguments] || {})
self.class.usage(**args)
end
#
# The cooldown for this command
#
# @return [ESM::Cooldown]
#
def current_cooldown
@current_cooldown ||= current_cooldown_query.first
end
#
# The ESM representation of a community's Arma 3 Server
#
# @return [ESM::Server, nil] The server that the command was executed for
#
def target_server
@target_server ||= lambda do
return unless arguments.server_id
ESM::Server.find_by_server_id(arguments.server_id)
end.call
end
#
# The ESM representation of a Discord server that is the target of this command
#
# @return [ESM::Community, nil] The community that the command was executed for
#
def target_community
@target_community ||= lambda do
return ESM::Community.find_by_community_id(arguments.community_id) if arguments.community_id
target_server&.community
end.call
end
#
# The ESM representation of a Discord user that is the target of this command
# This method is expected to only execute the code once.
# This avoids sending invalid IDs to Discord over and over again
#
# @return [ESM::User, ESM::User::Ephemeral, nil] The user that the command was executed against
#
def target_user
@target_user ||= lambda do
return if arguments.target.blank?
# This could be a steam_uid, discord id, or discord mention
# Automatically remove the mention characters
target_string = arguments.target.gsub(/[<@!&>]/, "").strip
# Attempt to find the target within ESM
user = ESM::User.parse(target_string)
# This validates that the user exists and we get a discord user back
return user if user&.discord_user
if target_string.discord_id?
discord_user = ESM.discord_bot.user(target_string)
# The target_string does not exist in the database
# but it is a valid discord user
return ESM::User.from_discord(discord_user) if discord_user
end
# The target_string does not exist in the database, nor in discord
return ESM::User::Ephemeral.new(target_string) if target_string.steam_uid?
# The provided text was gibberish
nil
end.call
end
#
# Sometimes we're given a steam UID that may not be linked to a discord user
# But, the command can work without the registered part.
#
# @return [String, nil] The steam uid from given argument or the steam uid registered to the target_user (which may be nil)
#
def target_uid
return if arguments.target.blank?
@target_uid ||= lambda do
arguments.target.steam_uid? ? arguments.target : target_user&.steam_uid
end.call
end
#
# The community, in which this command is being executed, command permissions
#
# @return [ESM::CommandConfiguration, nil]
#
def community_permissions
@community_permissions ||= lambda do
community = target_community || current_community
return unless community
community.command_configurations.where(command_name: command_name).first
end.call
end
#
# Is the current_user also the target_user?
#
# @return [Boolean]
#
def same_user?
return false if target_user.nil?
current_user.steam_uid == target_user.steam_uid
end
#
# Is the command limited to Direct Messages?
#
# @return [Boolean]
#
def dm_only?
limited_to == :dm
end
#
# Is the command limited to text channels?
#
# @return [Boolean]
#
def text_only?
limited_to == :text
end
#
# Returns if the current channel is a text channel
# @note Discordrb has helpers for this, but they're buggy?
#
# @return [<Type>] <description>
#
def text_channel?
return false if current_channel.nil?
current_channel.type == Discordrb::Channel::TYPES[:text]
end
#
# Returns if the current channel is a direct message with the user
# @note Discordrb has helpers for this, but they're buggy?
#
# @return [TrueClass, FalseClass]
#
def dm_channel?
return false if current_channel.nil?
current_channel.type == Discordrb::Channel::TYPES[:dm]
end
#
# Is the command limited to developers only?
#
# @return [Boolean]
#
def dev_only?
requirements.dev?
end
#
# Does the command require registration?
#
# @return [Boolean]
#
def registration_required?
requirements.registration?
end
#
# Is this command on cooldown?
#
# @return [Boolean]
#
def on_cooldown?
# We've never used this command with these arguments before
return false if current_cooldown.nil?
current_cooldown.active?
end
#
# Makes calls to I18n.t shorter
#
def t(translation_name, **)
I18n.t("commands.#{name}.#{translation_name}", **)
end
def argument?(argument_name)
arguments.key?(argument_name) || arguments.display_name_mapping.key?(argument_name)
end
def to_h
{
name: name,
arguments: arguments,
current_community: current_community&.attributes,
current_channel: current_channel&.attributes,
current_user: current_user&.attributes,
current_cooldown: current_cooldown&.attributes,
target_community: target_community&.attributes,
target_server: target_server&.attributes&.except("server_key"),
target_user: target_user&.attributes,
target_uid: target_uid,
same_user: same_user?,
dm_only: dm_only?,
text_only: text_only?,
dev_only: dev_only?,
registration_required: registration_required?,
on_cooldown: on_cooldown?,
skipped_actions: skipped_actions.to_h,
permissions: {
config: community_permissions&.attributes,
allowlist_enabled: command_allowlist_enabled?,
enabled: command_enabled?,
allowed: command_allowed_in_channel?,
allowlisted: command_allowed?,
notify_when_disabled: notify_when_command_disabled?,
cooldown_time: cooldown_time
}
}
end
def inspect
"<#{self.class.name}, arguments: #{arguments}>"
end
#
# Builds a message and raises a CheckFailure with that reason.
#
# @param error_name [String, Symbol, nil] The name of the error message located in the locales for "commands.<command_name>.errors". If nil, a block must be provided
# @param args [Hash] The args to be passed into the translation if an error_name is provided
# @param block [Proc] If provided, the block must return the error message to be used. This can be a string or an ESM::Embed.
#
def raise_error!(error_name = nil, **args, &block)
exception_class = args.delete(:exception_class) || ESM::Exception::CheckFailure
path_prefix = args.delete(:path_prefix) || "commands.#{name}.errors"
reason =
if block
yield
elsif error_name
ESM::Embed.build(:error, description: I18n.t("#{path_prefix}.#{error_name}", **args))
end
warn!(
exception_class:,
reason: reason.is_a?(Embed) ? reason.description : reason,
command: to_h,
**(@origin&.log_context || {})
)
raise exception_class, reason
end
def skip_action(*)
skipped_actions.set(*, unset: false)
end
#
# Sends a Message to the target server.
#
# @param message [Message] The message to send to the target server
# @param block [Boolean] Whether to wait until the server responds back
# (defaults to true)
#
# @return [Message, Connection::Promise]
# If block is false, a promise in a processing status is returned.
# If block is true, the response as ESM::Message is returned.
#
# @raise [Exception::CheckFailure] If the command does not have a valid target server
# @raise [Exception::RejectedPromise] If the promise is rejected
# @raise [Exception::ExtensionError] If there's an extension error
#
def send_to_target_server!(message, block: true)
raise ArgumentError, "Message must be a ESM::Message" unless message.is_a?(ESM::Message)
raise_missing_target_server! if target_server.nil?
message = message.set_metadata(
player: current_user,
target: target_user
)
target_server.send_message(message, block:)
end
#
# Raised when a command tries to reach a server without a valid target_server,
# which means the command is missing its `server_id` argument.
#
def raise_missing_target_server!
raise ESM::Exception::CheckFailure,
"Command #{name} must define the `server_id` argument in order to talk to its server"
end
#
# Shorthand method for sending a query message to the Exile database
#
# @param name [String, Symbol] The name of the query
# @param **arguments [Hash] The query arguments
#
# @return [ESM::Message] The response
#
# @raise (see #send_to_target_server!)
#
def query_exile_database!(name, **arguments)
raise_missing_target_server! if target_server.nil?
target_server.query_exile_database!(
name,
player: current_user,
target: target_user,
**arguments
)
end
alias_method :run_database_query!, :query_exile_database!
#
# Calls the provided missionNamespace variable with the provided arguments
#
# @param function_name [String] The missionNamespace variable that holds code
# @param arguments [Hash] Any additional arguments
#
# @return [ESM::Message] The response
#
# @raise (see #send_to_target_server!)
#
def call_sqf_function!(function_name, **arguments)
raise_missing_target_server! if target_server.nil?
target_server.call_sqf_function!(
function_name,
player: current_user,
target: target_user,
**arguments
)
end
# Convenience method for replying back to whoever invoked the command.
def reply(message)
@origin.reply(message)
end
def edit_message(message, content)
if content.is_a?(ESM::Embed)
embed = Discordrb::Webhooks::Embed.new
content.transfer(embed)
message.edit("", embed)
else
message.edit(content)
end
end
def request
@request ||= lambda do
requestee = target_user || current_user
# Don't look for the requestor because multiple different people could attempt to invite them
# requestor_user_id: current_user.esm_user.id,
query = ESM::Request.where(requestee_user_id: requestee.id, command_name: command_name)
arguments.to_h.each do |name, value|
query =
if value.nil?
query.where("command_arguments->>'#{name}' IS NULL")
else
query.where("command_arguments->>'#{name}' = ?", value)
end
end
query.first
end.call
end
def add_request(to:, description: "")
@request =
ESM::Request.create!(
requestor_user_id: current_user.id,
requestee_user_id: to.id,
requested_from_channel_id: current_channel.id.to_s,
command_name: command_name,
command_arguments: arguments.to_h
)
send_request_message(description: description, target: to)
end
def request_url
ESM.config.request_url
end
def accept_request_url(uuid)
"#{request_url}/#{uuid}/accept"
end
def decline_request_url(uuid)
"#{request_url}/#{uuid}/decline"
end
def send_request_message(target:, description: "")
embed =
ESM::Embed.build do |e|
e.set_author(name: current_user.distinct, icon_url: current_user.avatar_url)
e.description = description
e.add_field(name: I18n.t("commands.request.accept_name"), value: I18n.t("commands.request.accept_value", url: accept_request_url(request.uuid)), inline: true)
e.add_field(name: I18n.t("commands.request.decline_name"), value: I18n.t("commands.request.decline_value", url: decline_request_url(request.uuid)), inline: true)
e.add_field(name: I18n.t("commands.request.command_usage_name"), value: I18n.t("commands.request.command_usage_value", uuid: request.uuid_short))
end
ESM.discord_bot.deliver(embed, to: target)
end
def create_or_update_cooldown
@current_cooldown = current_cooldown_query.first_or_create
current_cooldown.update_expiry!(timers.on_execute.started_at, cooldown_time)
end
def current_cooldown_query
query = ESM::Cooldown.where(command_name: command_name)
# If the command requires a steam_uid, use it to track the cooldown.
query =
if registration_required?
query.where(steam_uid: current_user.steam_uid)
else
query.where(user_id: current_user.id)
end
# Check for the target_community
query = query.where(community_id: target_community.id) if target_community
# If we don't have a target_community, use the current_community (if applicable)
query = query.where(community_id: current_community.id) if current_community && target_community.nil?
# Check for the individual server
query = query.where(server_id: target_server.id) if target_server
# Return the query
query
end
#
# Attempts to create an embed using data from the client.
# This checks for valid attributes, and invalid attributes
#
# @param message [Message, Hash]
#
# @return [ESM::Embed]
#
def embed_from_message!(message_or_hash)
hash =
if message_or_hash.is_a?(Message)
message_or_hash.data.to_h
else
message_or_hash
end
Embed.from_hash!(hash)
rescue ArgumentError => e
target_server.connection.send_error(e)
# Sorry user... The admins need to fix their shit
raise_error!(
:error,
path_prefix: "exceptions.extension",
user: current_user.mention,
server_id: target_server.server_id
)
end
alias_method :embed_from_hash!, :embed_from_message!
end
end
end
end