lib/nostrum/util.ex

defmodule Nostrum.Util do
  @moduledoc """
  Utility functions
  """

  @gateway_url_key :nostrum_gateway_url

  alias Nostrum.{Api, Constants, Snowflake}
  alias Nostrum.Shard.Session
  alias Nostrum.Struct.WSState

  require Logger

  @doc """
  Returns the number of milliseconds since unix epoch.
  """
  @spec now() :: integer
  def now do
    DateTime.utc_now()
    |> DateTime.to_unix(:millisecond)
  end

  @doc """
  Returns the number of microseconds since unix epoch.
  """
  @spec usec_now() :: integer
  def usec_now do
    DateTime.utc_now()
    |> DateTime.to_unix(:microsecond)
  end

  @doc """
  Returns the current date as an ISO formatted string.
  """
  @spec now_iso() :: String.t()
  def now_iso do
    DateTime.utc_now()
    |> DateTime.to_iso8601()
  end

  @doc false
  def list_to_struct_list(list, struct) when is_list(list) do
    Enum.map(list, &struct.to_struct(&1))
  end

  def enum_to_struct(nil, _struct), do: nil
  def enum_to_struct(enum, struct) when is_list(enum), do: Enum.map(enum, &struct.to_struct(&1))

  def enum_to_struct(enum, struct) when is_map(enum) do
    for {k, v} <- enum, into: %{} do
      {k, struct.to_struct(v)}
    end
  end

  @doc """
  Returns the total amount of shards as per the configuration.

  ## Return value

  - If you specified your shards as `:auto`, the return value will be the
  recommended number of shards as given by the gateway.

  - If you explicitly specified your shard numbers as an integer, it will be
  the given number.

  - If you specified your shards in the form `{lowest, highest, total}` to
  start a specific range of the total shards you want to start, this will be
  the `total` value.

  Should Discord not supply us with any shard information, this will return
  `1`.

  Note that this is not the number of currently active shards, but the number
  of shards specified in your config.
  """
  @spec num_shards() :: pos_integer()
  def num_shards do
    num =
      with :auto <- Application.get_env(:nostrum, :num_shards, :auto),
           {_url, shards} <- gateway() do
        shards
      end

    case num do
      {_lowest, _highest, total} -> total
      nil -> 1
    end
  end

  @doc false
  def bangify_find(to_bang, find, cache_name) do
    case to_bang do
      {:ok, res} ->
        res

      {:error} ->
        raise(Nostrum.Error.CacheError, finding: find, cache_name: cache_name)

      {:error, _other} ->
        raise(Nostrum.Error.CacheError, finding: find, cache_name: cache_name)
    end
  end

  @doc """
  Returns the gateway url and shard count for current websocket connections.

  If by chance no gateway connection has been made, will fetch the url to use and store it
  for future use.
  """
  @spec gateway() :: {String.t(), integer}
  def gateway do
    case :persistent_term.get(@gateway_url_key, nil) do
      nil -> get_new_gateway_url()
      result -> result
    end
  end

  defp get_new_gateway_url do
    case Api.request(:get, Constants.gateway_bot()) do
      {:error, %{status_code: 401}} ->
        raise("Authentication rejected, invalid token")

      {:error, %{status_code: code, response: %{message: message}}} ->
        raise(Nostrum.Error.ApiError, status_code: code, message: message)

      {:ok, body} ->
        body = Jason.decode!(body)

        "wss://" <> url = body["url"]
        shards = if body["shards"], do: body["shards"], else: 1

        :persistent_term.put(@gateway_url_key, {url, shards})
        {url, shards}
    end
  end

  @doc """
  Converts a map into an atom-keyed map.

  Given a map with variable type keys, returns the same map with all keys as `atoms`.
  To support maps keyed with integers (such as in Discord's interaction data),
  binaries that appear to be integers will be parsed as such.

  This function will attempt to convert keys to an existing atom, and if that fails will default to
  creating a new atom while displaying a warning. The idea here is that we should be able to see
  if any results from Discord are giving variable keys. Since we *will* define all
  types of objects returned by Discord, the amount of new atoms created *SHOULD* be 0. 👀
  """
  @spec safe_atom_map(map) :: map
  def safe_atom_map(term) do
    case term do
      # to handle the rare occasion that discord leaks a `:__struct__` key
      # rather than outright crashing, we'll just log a warning and continue
      %{__struct__: struct_name} ->
        Logger.warning(
          "Discord's gateway leaked a struct with name #{inspect(struct_name)}, please report this to the library maintainer"
        )

        term = Map.from_struct(term)
        for {key, value} <- term, into: %{}, do: {maybe_to_atom(key), safe_atom_map(value)}

      # if we have a regular map
      %{} ->
        for {key, value} <- term, into: %{}, do: {maybe_to_atom(key), safe_atom_map(value)}

      # if we have a non-empty list
      [_ | _] ->
        Enum.map(term, fn item -> safe_atom_map(item) end)

      _ ->
        term
    end
  end

  @doc """
  Attempts to convert a string to an atom.

  Binary `token`s that consist of digits are assumed to be snowflakes, and will
  be parsed as such.

  If atom does not currently exist, will warn that we're doing an unsafe conversion.
  """
  @spec maybe_to_atom(atom | String.t()) :: atom | integer
  def maybe_to_atom(token) when is_atom(token), do: token

  def maybe_to_atom(<<head, _rest::binary>> = token) when head in ?1..?9 do
    case Integer.parse(token) do
      {snowflake, ""} ->
        snowflake

      _ ->
        :erlang.binary_to_atom(token)
    end
  end

  def maybe_to_atom(token) do
    String.to_existing_atom(token)
  rescue
    _ ->
      Logger.debug(fn -> "Converting string to non-existing atom: #{token}" end)
      String.to_atom(token)
  end

  @doc """
  Converts possibly nil ISO8601 timestamp to a `DateTime`
  """
  @spec maybe_to_datetime(String.t() | nil) :: DateTime.t() | nil
  def maybe_to_datetime(nil) do
    nil
  end

  def maybe_to_datetime(stamp) do
    {:ok, casted, 0} = DateTime.from_iso8601(stamp)
    casted
  end

  @doc """
  Converts possibly nil ISO8601 timestamp to unix time.
  """
  @spec maybe_to_unixtime(String.t() | nil) :: pos_integer() | nil
  def maybe_to_unixtime(nil) do
    nil
  end

  def maybe_to_unixtime(stamp) do
    stamp
    |> maybe_to_datetime()
    |> DateTime.to_unix()
  end

  # Generic casting function
  @doc false
  @spec cast(term, module | {:list, term} | {:struct, term} | {:index, [term], term}) :: term
  def cast(value, type)
  def cast(nil, _type), do: nil

  def cast(values, {:list, type}) when is_list(values) do
    Enum.map(values, fn value ->
      cast(value, type)
    end)
  end

  # Handles the case where the given term is already indexed
  def cast(values, {:index, _index_by, _type}) when is_map(values), do: values

  def cast(values, {:index, index_by, type}) when is_list(values) do
    values
    |> Enum.into(%{}, &{&1 |> get_in(index_by) |> cast(Snowflake), cast(&1, type)})
  end

  def cast(value, {:struct, module}) when is_map(value) do
    module.to_struct(value)
  end

  def cast(value, module) do
    case module.cast(value) do
      {:ok, result} -> result
      _ -> value
    end
  end

  @doc false
  @spec fullsweep_after() :: {:fullsweep_after, non_neg_integer}
  def fullsweep_after do
    {:fullsweep_after,
     Application.get_env(
       :nostrum,
       :fullsweep_after_default,
       :erlang.system_info(:fullsweep_after) |> elem(1)
     )}
  end

  @doc """
  Gets the latency of the shard connection from a `Nostrum.Struct.WSState.t()` struct.

  Returns the latency in milliseconds as an integer, returning nil if unknown.
  """
  @spec get_shard_latency(WSState.t()) :: non_neg_integer | nil
  def get_shard_latency(%WSState{last_heartbeat_ack: nil}), do: nil

  def get_shard_latency(%WSState{last_heartbeat_send: nil}), do: nil

  def get_shard_latency(%WSState{} = state) do
    latency = DateTime.diff(state.last_heartbeat_ack, state.last_heartbeat_send, :millisecond)
    max(0, latency + if(latency < 0, do: state.heartbeat_interval, else: 0))
  end

  @doc """
  Gets the latencies of all shard connections.

  Calls `get_shard_latency/1` on all shards and returns a map whose keys are
  shard nums and whose values are latencies in milliseconds.
  """
  @spec get_all_shard_latencies :: %{WSState.shard_num() => non_neg_integer | nil}
  def get_all_shard_latencies do
    Nostrum.Shard.Supervisor
    |> Supervisor.which_children()
    |> Enum.filter(fn {_id, _pid, _type, [modules]} -> modules == Nostrum.Shard end)
    |> Enum.map(fn {_id, pid, _type, _modules} -> Supervisor.which_children(pid) end)
    |> List.flatten()
    |> Enum.map(fn {_id, pid, _type, _modules} -> Session.get_ws_state(pid) end)
    |> Enum.reduce(%{}, fn {_, s}, m -> Map.put(m, s.shard_num, get_shard_latency(s)) end)
  end

  @doc """
  Since we're being sacrilegious and converting strings to atoms from the WS, there will be some
  atoms that we see that aren't defined in any Discord structs. This method mainly serves as a
  means to define those atoms once so the user isn't warned about them in the
  `Nostrum.Util.maybe_to_atom/1` function when they are in fact harmless.

  The function is public to prevent it from being optimized out at compile time.
  """
  def unused_atoms do
    [
      :active,
      :audio,
      :app_permissions,
      :audio_codec,
      :audio_ssrc,
      :burst,
      :channel_overrides,
      :convert_emoticons,
      :detect_platform_accounts,
      :developer_mode,
      :enable_tts_command,
      :encodings,
      :entitlement_sku_ids,
      :entitlements,
      :experiments,
      :friend_source_flags,
      :friend_sync,
      :guild_positions,
      :inline_attachment_media,
      :inline_embed_media,
      :last_message_id,
      :locale,
      :max_bitrate,
      :media_session_id,
      :message_author_id,
      :message_display_compact,
      :message_notifications,
      :mobile_push,
      :modes,
      :muted,
      :recipients,
      :referenced_message,
      :render_embeds,
      :render_reactions,
      :require_colons,
      :restricted_guilds,
      :rid,
      :rtx_ssrc,
      :scale_resolution_down_by,
      :show_current_game,
      :suppress_everyone,
      :theme,
      :video,
      :video_codec,
      :video_ssrc,
      :visibility
    ]
  end
end