lib/nosedrum/storage.ex

defmodule Nosedrum.Storage do
  @moduledoc """
  `Storage`s keep track of your Application Command names and their associated modules.

  A `Storage` handles incoming `t:Nostrum.Struct.Interaction.t/0`s, invoking
  `c:Nosedrum.ApplicationCommand.command/1` callbacks and responding to the Interaction.

  In addition to tracking commands locally for the bot, a `Storage` is
  responsible for registering an Application Command with Discord when `c:add_command/4`
  or `c:remove_command/4` is called.
  """
  @moduledoc since: "0.4.0"
  alias Nostrum.Struct.{Guild, Interaction}

  @callback_type_map %{
    pong: 1,
    channel_message_with_source: 4,
    deferred_channel_message_with_source: 5,
    deferred_update_message: 6,
    update_message: 7
  }

  @flag_map %{
    ephemeral?: 64
  }

  @type command_scope :: :global | Guild.id() | [Guild.id()]

  @typedoc """
  Defines a structure of commands, subcommands, subcommand groups.

  **Note** that Discord only supports nesting 3 levels deep, like `command -> subcommand group -> subcommand`.

  ## Example path:
  ```elixir
  %{
    {"castle", MyApp.CastleCommand.description()} =>
      %{
        {"prepare", "Prepare the castle for an attack."} => [],
        {"open", "Open up the castle for traders and visitors."} => [],
        # ...
      }
  }
  ```

  ## References
  - Official Documentation:
  https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups
  """
  @type application_command_path ::
          %{
            {group_name :: String.t(), group_desc :: String.t()} => [
              application_command_path | [Nosedrum.ApplicationCommand.option()]
            ]
          }

  @typedoc """
  The name or pid of the Storage process.
  """
  @type name_or_pid :: atom() | pid()

  @doc """
  Handle an Application Command invocation.

  This callback should be invoked upon receiving an interaction via the `:INTERACTION_CREATE` event.

  ## Example using `Nosedrum.Storage.Dispatcher`:
  ```elixir
  # In your `Nostrum.Consumer` file:
  def handle_event({:INTERACTION_CREATE, interaction, _ws_state}) do
    IO.puts "Got interaction"
    Nosedrum.Storage.Dispatcher.handle_interaction(interaction)
  end
  ```

  ## Return value

  Returns `{:ok}` on success, or `{:ok, t:Nostrum.Struct.Message.t()}` when deferring and the supplied callback
  completes with a successful edit of the original response. `{:error, reason}` is returned otherwise.
  """
  @callback handle_interaction(interaction :: Interaction.t(), name_or_pid) ::
              {:ok}
              | {:ok, Nostrum.Struct.Message.t()}
              | {:error, :unknown_command}
              | Nostrum.Api.error()

  @doc """
  Add a new command under the given name or application command path.

  If the command already exists, it will be overwritten.

  ## Return value
  Returns `:ok` if successful, and `{:error, reason}` otherwise.
  """
  @callback add_command(
              name_or_path :: String.t() | application_command_path,
              command_module :: module,
              scope :: command_scope,
              name_or_pid
            ) :: :ok | {:error, Nostrum.Error.ApiError.t()}

  @doc """
  Remove the command under the given name or application command path.

  ## Return value

  Returns `:ok` if successful, and `{:error, reason}` otherwise.

  If the command does not exist, no error should be returned.
  """
  @callback remove_command(
              name_or_path :: String.t() | application_command_path,
              command_id :: Nostrum.Snowflake.t(),
              scope :: command_scope,
              name_or_pid
            ) :: :ok | {:error, Nostrum.Error.ApiError.t()}

  @doc """
  Responds to an Interaction with the given `t:Nosedrum.ApplicationCommand.response/0`.

  ## Return value

  Returns `{:ok}` if successful, and a `t:Nostrum.Api.error/0` otherwise.
  """
  @spec respond(Interaction.t(), Nosedrum.ApplicationCommand.response()) ::
          {:ok} | Nostrum.Api.error()
  def respond(interaction, command_response) do
    type =
      command_response
      |> Keyword.get(:type, :channel_message_with_source)
      |> convert_callback_type()

    data =
      command_response
      |> Keyword.take([:content, :embeds, :components, :tts?, :allowed_mentions])
      |> Map.new()
      |> put_flags(command_response)

    res = %{
      type: type,
      data: data
    }

    Nostrum.Api.create_interaction_response(interaction, res)
  end

  @doc """
  Edits an interaction with a follow up response.

  The response is obtained by running the given function/MFA tuple, see
  `t:Nosedrum.ApplicationCommand.callback/0`.

  ## Return value

  Returns `{:ok, `t:Nostrum.Struct.Message.t()`}` if successful, and a `t:Nostrum.Api.error/0` otherwise.
  """
  @spec followup(Interaction.t(), Nosedrum.ApplicationCommand.callback()) ::
          {:ok, Nostrum.Struct.Message.t()} | Nostrum.Api.error()
  def followup(interaction, callback_tuple) do
    followup_response =
      case callback_tuple do
        {callback, args} -> apply(callback, args)
        {module, callback, args} -> apply(module, callback, args)
      end

    data =
      followup_response
      |> Keyword.take([:content, :embeds, :components, :allowed_mentions])
      |> Map.new()

    Nostrum.Api.edit_interaction_response(interaction, data)
  end

  defp convert_callback_type({type, _fn}) do
    convert_callback_type(type)
  end

  defp convert_callback_type(type) do
    Map.get(@callback_type_map, type)
  end

  defp put_flags(data_map, command_response) do
    Enum.reduce(@flag_map, data_map, fn {flag, value}, data_map_acc ->
      if command_response[flag] do
        Map.put(data_map_acc, :flags, value)
      else
        data_map_acc
      end
    end)
  end
end