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