lib/gen_nostr.ex

defmodule GenNostr do
  @moduledoc """
  A low level websocket client that implements the `Nostr` protocol.

  It works like a GenServer.

  ## Architecture

  The GenNostr process spawn a PrimarySupervisor with a ConnectionRegistry
  and a ConnectionSupervisor (DynamicSupervisor) that supervises connections.
  The ConnectionRegistry store the url as key to avoid connections to same
  relay.

  The GenNostr process is the client and the websocket processes are then
  connections. The client send `commands` to connections. The connections emit
  `events` that can to be generated by the commands or by websocket messages
  and sends back the response to client through handlers.
  """

  alias GenNostr.{Connection, Commands, Relay, Options}

  #####################
  # GenServer callbacks

  @doc """
  Invoked when the gen_nostr process starts.

  Behaves the same as `c:GenServer.init/1`

      @impl GenNostr
      def init(args) do
        relays = Keyword.get(args, :relays, [])
        options = Keyword.get(args, :options, [])

        Enum.each(relays, &GenNostr.add_relay(&1, options))

        {:ok, %{}}
      end
  """
  @callback init(init_arg :: term()) ::
              {:ok, state}
              | {:ok, state, timeout() | :hibernate | {:continue, continue_arg :: term()}}
              | :ignore
              | {:stop, reason :: any()}
            when state: term()

  @doc """
  Invoked when a GenNostr process receives a GenServer call.

  Behaves the same as `c:GenServer.handle_call/3`
  """
  @callback handle_call(
              request :: term(),
              from :: GenServer.from(),
              state :: term()
            ) ::
              {:reply, reply, new_state}
              | {:reply, reply, new_state, timeout() | :hibernate | {:continue, term()}}
              | {:noreply, new_state}
              | {:noreply, new_state, timeout() | :hibernate | {:continue, term()}}
              | {:stop, reason, new_state}
              | {:stop, reason, reply, new_state}
            when new_state: term(), reply: term(), reason: term()

  @doc """
  Invoked when a GenNostr process receives a GenServer cast.

  Behaves the same as `c:GenServer.handle_cast/2`
  """
  @callback handle_cast(request :: term(), state :: term()) ::
              {:noreply, new_state}
              | {:noreply, new_state, timeout() | :hibernate | {:continue, term()}}
              | {:stop, reason :: term(), new_state}
            when new_state: term()

  @doc """
  Invoked when a GenNostr process receives a message.

  Behaves the same as `c:GenServer.handle_info/2`
  """
  @callback handle_info(msg :: :timeout | term(), state :: term()) ::
              {:noreply, new_state}
              | {:noreply, new_state, timeout() | :hibernate | {:continue, term()}}
              | {:stop, reason :: term(), new_state}
            when new_state: term()

  @doc """

  Invoked when a GenNostr process receives a message.

  Behaves the same as `c:GenServer.handle_continue/2`
  """
  @callback handle_continue(continue_arg, state :: term()) ::
              {:noreply, new_state}
              | {:noreply, new_state, timeout() | :hibernate | {:continue, continue_arg}}
              | {:stop, reason :: term(), new_state}
            when new_state: term(), continue_arg: term()

  @doc """
  Invoked when a GenNostr process is terminated.

  Note that this callback is not always invoked as the process shuts down.
  See `c:GenServer.terminate/2` for more information.
  """
  @callback terminate(reason, state :: term()) :: term()
            when reason: :normal | :shutdown | {:shutdown, term()} | term()

  ####################
  # GenNostr Callbacks

  @doc """
  Invoked when a connection has been established to a relay.
  """
  @callback handle_connect(relay :: Relay.t(), state :: term()) ::
              {:ok, new_state}
              | {:stop, reason :: term(), new_state}
            when new_state: term()

  @doc """
  Invoked when a gen_nostr connection is disconected from relay.
  """
  @callback handle_disconnect(reason :: term(), relay :: Relay.t(), state :: term()) ::
              {:ok, new_state}
              | {:stop, stop_reason :: term(), new_state}
            when new_state: term()

  @doc """
  Invoked when a gen_nostr connection has a error and can trigger a
  `handle_disconnect/3` if the error close the connection.
  """
  @callback handle_error(reason :: term(), relay :: Relay.t(), state :: term()) ::
              {:ok, new_state}
              | {:stop, stop_reason :: term(), new_state}
            when new_state: term()

  @doc """
  Invoked when a gen_nostr connection receives a EVENT message (NIP-01).
  """
  @callback handle_event(event_msg :: term(), relay :: Relay.t(), state :: term()) ::
              {:ok, new_state}
              | {:stop, new_state}
              | {:stop, reason :: term(), new_state}
            when new_state: term()

  @doc """
  Invoked when a gen_nostr connection receives a NOTICE message (NIP-01).
  """
  @callback handle_notice(notice_msg :: term(), relay :: Relay.t(), state :: term()) ::
              {:ok, new_state}
              | {:stop, new_state}
              | {:stop, reason :: term(), new_state}
            when new_state: Relay.t()

  @doc """
  Invoked when a gen_nostr connection receives a EOSE message (NIP-15).
  """
  @callback handle_eose(eose_msg :: term(), relay :: Relay.t(), state :: term()) ::
              {:ok, new_state}
              | {:stop, new_state}
              | {:stop, reason :: term(), new_state}
            when new_state: term()

  @doc """
  Invoked when a gen_nostr connection receives a OK message (NIP-20).
  """
  @callback handle_ok(ok_msg :: term(), relay :: Relay.t(), state :: term()) ::
              {:ok, new_state}
              | {:stop, new_state}
              | {:stop, reason :: term(), new_state}
            when new_state: term()

  @doc """
  Invoked when a gen_nostr connection receives a AUTH message (NIP-42).
  """
  @callback handle_auth(auth_msg :: term(), relay :: Relay.t(), state :: term()) ::
              {:ok, new_state}
              | {:stop, new_state}
              | {:stop, reason :: term(), new_state}
            when new_state: term()

  @optional_callbacks [
    # GenServer
    init: 1,
    handle_call: 3,
    handle_cast: 2,
    handle_info: 2,
    handle_continue: 2,
    terminate: 2,

    # GenNostr
    handle_connect: 2,
    handle_disconnect: 3,
    handle_error: 3,
    handle_event: 3,
    handle_notice: 3,
    handle_eose: 3,
    handle_ok: 3,
    handle_auth: 3
  ]

  ######################
  # Behaviour public API

  @doc """
  Starts a GenNostr client process.

  This function return a `GenServer.start_link/3`

  ## Examples

      defmodule NostrClient do
        use GenNostr

        def start_link(args) do
          GenNostr.start_link(__MODULE__, args, name: __MODULE__)
        end
      end
  """
  @spec start_link(module(), term(), GenServer.options()) :: GenServer.on_start()
  def start_link(module, args, options \\ []) when is_atom(module) and is_list(options) do
    GenServer.start_link(module, args, options)
  end

  ######################
  # Functions public API

  @doc """
  Crate a new connection to relay.

  ## Options

  * `:mint` - keyword list of specific options of elixir mint, default to `[protocols: [:http1]]`

  * `:reconnect` - timeouts list in msec used by `reconnect/1`, default to `[500, 1_000, 5_000, 10_000, 30_000]`

  * `:tasks` - keyword list of recurring timeout tasks in msec, default to `[garbage_collector: 60 * 60 * 1000]`

  ## Examples

      # using a url and default options
      GenNostr.add_relay("wss://relay.com/")

      # using the Relay struct and default options
      GenNostr.Relay.new(url: "wss://relay.com/")
      |> GenNostr.add_relay()
  """
  @spec add_relay(String.t() | Relay.t(), keyword()) :: term()
  def add_relay(%Relay{} = relay) do
    Connection.execute(%Commands.AddRelay{relay: relay})
  end

  def add_relay(url, options \\ []) when is_binary(url) and is_list(options) do
    relay = Relay.new(url: url, options: Options.new(options))
    Connection.execute(%Commands.AddRelay{relay: relay})
  end

  @doc """
  Finishes the connection and removes the relay.

  This function invokes `handle_disconnect/3` and receive a reason of `:remove_relay`

  ## Examples

      # using a url and default options
      GenNostr.remove_relay("wss://relay.com/")

      # using the Relay struct and default options
      GenNostr.Relay.new(url: "wss://relay.com/")
      |> GenNostr.remove_relay()
  """
  @spec remove_relay(String.t() | Relay.t()) :: term()
  def remove_relay(%Relay{} = relay) do
    Connection.execute(%Commands.RemoveRelay{relay: relay})
  end

  def remove_relay(url) when is_binary(url) do
    Connection.execute(%Commands.RemoveRelay{relay: Relay.new(url: url)})
  end

  @doc """
  Tries to reconnect after a disconnection.

  This function must be used within `handle_disconnect/3`.

  ## Examples

      @impl GenNostr
      def handle_disconnect(reason, relay, state) do
        Logger.info("Disconnected from relay")

        # don't reconnect if the :remove_relay is the reason
        case reason do
          :remove_relay -> Logger.info("Don't reconnect")
          _ -> GenNostr.reconnect(relay)
        end

        {:ok, state}
      end
  """
  @spec reconnect(Relay.t()) :: term()
  def reconnect(relay) do
    Connection.execute(%Commands.Reconnect{relay: relay})
  end

  @doc """
  Relay url list of all current connections.
  """
  @spec list_relays() :: [String.t()]
  def list_relays() do
    Connection.execute(%Commands.ListRelays{})
  end

  @doc """
  Send a message to the specified url or relay.
  """
  @spec send_message(String.t(), String.t() | Relay.t()) :: any()
  def send_message(message, %Relay{} = relay) do
    Connection.execute(%Commands.SendMessage{url: relay.url, message: message})
  end

  def send_message(message, url) when is_binary(url) do
    Connection.execute(%Commands.SendMessage{url: url, message: message})
  end

  @doc """
  Send a broadcast message to connected relays.
  """
  @spec broadcast_message(String.t()) :: any()
  def broadcast_message(message) do
    Connection.execute(%Commands.BroadcastMessage{message: message})
  end

  @doc false
  defmacro __using__(opts) do
    quote location: :keep, bind_quoted: [opts: opts] do
      @doc false
      def child_spec(arg) do
        default = %{
          id: __MODULE__,
          start: {__MODULE__, :start_link, [arg]}
        }

        Supervisor.child_spec(default, unquote(Macro.escape(opts)))
      end

      defoverridable child_spec: 1

      require GenNostr.Signatures

      @behaviour GenNostr

      # start the PrimarySupervisor
      GenNostr.PrimarySupervisor.start_link(__MODULE__)

      @impl GenNostr
      def handle_info(GenNostr.Signatures.event(event), state) do
        GenNostr.Callback.dispatch(__MODULE__, event, state)
      end

      @impl GenNostr
      def handle_cast(
            GenNostr.Signatures.command(%GenNostr.Commands.Reconnect{relay: relay}),
            state
          ) do
        GenNostr.add_relay(relay)
        {:noreply, state}
      end
    end
  end
end