Packages

A Nostr client library for Elixir. Uses `nostr_lib` and `mint` to connect to relays, send events, and subscribe to relays.

Current section

Files

Jump to
nostr_ex lib client.ex
Raw

lib/client.ex

defmodule NostrEx.Client do
@moduledoc """
Client operations for the Nostr protocol.
This module handles the core protocol operations like signing events,
serializing messages, and managing the WebSocket communication layer.
Most users should use the higher-level `NostrEx` module instead.
## Event Creation and Signing
iex> Client.sign_and_send_event(%Event{...}, private_key)
{:ok, "abcd123f"}
## Subscription Management
iex> Client.send_sub([authors: [pubkey], kinds: [1]])
{:ok, "subscription_id"}
"""
alias Nostr.{Event, Filter, Message}
alias NostrEx.{RelayAgent, RelayManager, Socket, Utils}
require Logger
# === Event Publishing ===
@doc """
Send a signed event as an `%Event{}` struct.
## Options
- `:send_via` - List of relays to send the event to. Defaults to all connected relays.
"""
@spec send_event(Event.t(), keyword()) ::
{:ok, binary()} | {:error, String.t() | [String.t()]}
def send_event(event, opts \\ [])
def send_event(%Nostr.Event{} = event, opts) do
with relay_names = get_relays(opts[:send_via]),
true <- relay_names != [],
payload <- serialize(event) do
{oks, errors} =
relay_names
|> Enum.map(&send_event_serialized(&1, payload))
|> Enum.split_with(&match?(:ok, &1))
count_errors = Enum.count(errors)
cond do
oks == [] ->
errors = Keyword.values(errors)
Logger.error("#{count_errors} send(s) failed with errors: #{Enum.join(errors, ", ")}")
{:error, errors}
errors != [] ->
Logger.error("#{event.id}: #{Enum.count(oks)} succeeded, #{count_errors} send(s) failed with errors: #{Enum.join(errors, ", ")}")
{:ok, event.id}
true ->
{:ok, event.id}
end
else
{:error, _} = err -> err
false -> {:error, "no valid relays found, got: #{inspect(opts[:send_via])}"}
end
end
@doc """
Send an event and the private key or Signer process to sign the event with.
"""
def sign_and_send_event(event, signer_or_privkey, opts \\ [])
@spec sign_and_send_event(Event.t(), binary() | struct(), keyword()) ::
{:ok, binary()} | {:error, String.t() | [String.t()]}
def sign_and_send_event(%Event{} = event, signer_or_privkey, opts) do
case sign_event(event, signer_or_privkey) do
{:ok, signed_event} -> send_event(signed_event, opts)
{:error, reason} -> {:error, reason}
end
end
def sign_and_send_event(_event, _signer_or_privkey, _opts),
do: {:error, "invalid event provided, must be an %Event{} struct."}
@doc """
Send a serialized payload to a specific relay.
`relay` must be a relay name registered in the RelayRegistry.
"""
@spec send_event_serialized(atom(), binary()) :: :ok | {:error, atom() | String.t()}
def send_event_serialized(relay, payload) when is_binary(payload) do
Socket.send_message(relay, payload)
end
# === Subscription Management ===
@doc """
Send a subscription request.
`filter` can be:
- A keyword list of filter arguments
- A list of keyword lists for multiple filters
Returns `{:ok, subscription_id}` on success.
"""
@spec send_sub(keyword() | [keyword()], keyword()) :: {:ok, String.t()} | {:error, String.t()}
def send_sub(filter, opts \\ []) do
with {:ok, filters} <- create_filters(filter),
{:ok, sub_id, message} <- create_subscription_message(filters),
{:ok, _pid} <- Registry.register(NostrEx.PubSub, sub_id, nil) do
opts[:send_via]
|> get_relays()
|> Enum.each(fn relay_name ->
subscribe(relay_name, sub_id, message)
end)
{:ok, sub_id}
else
{:error, reason} -> {:error, reason}
end
end
@doc """
Close a subscription by ID.
Sends CLOSE message to all relays that know about this subscription.
"""
@spec close_sub(String.t()) :: {:ok, [any()]} | {:error, String.t() | [any()]}
def close_sub(sub_id) when is_binary(sub_id) do
with true <- sub_id in RelayAgent.get_unique_subscriptions(),
relays <- RelayAgent.get_relays_for_sub(sub_id),
request = Message.close(sub_id) |> Message.serialize() do
relays
|> Enum.map(fn relay_name ->
case send_event_serialized(relay_name, request) do
{:ok, _} -> RelayAgent.delete_subscription(relay_name, sub_id)
err -> err
end
end)
|> NostrEx.Utils.collect()
else
false ->
{:error, "subscription ID not found: #{sub_id}"}
_ ->
{:error, "could not get relays for sub_id: #{sub_id}"}
end
end
@doc """
Close a connection to a relay by name or pid.
"""
@spec close_conn(atom()) :: :ok | {:error, :not_found}
def close_conn(relay_name) when is_atom(relay_name) do
case Registry.lookup(NostrEx.RelayRegistry, relay_name) do
[{pid, _}] -> close_conn(pid)
_ -> {:error, :not_found}
end
end
def close_conn(pid) when is_pid(pid), do: DynamicSupervisor.terminate_child(RelayManager, pid)
def close_conn(_), do: {:error, :not_found}
@doc """
Create filters from keyword list(s).
Returns `{:ok, [Filter.t()]}` on success.
"""
@spec create_filters(keyword() | [keyword()]) :: {:ok, [Filter.t()]} | {:error, String.t()}
def create_filters(opts) when is_list(opts) do
case opts do
[] ->
{:ok, []}
[{_key, _value} | _] ->
filter = Map.merge(%Filter{}, Enum.into(opts, %{}))
{:ok, [filter]}
filters when is_list(hd(filters)) ->
case Enum.all?(filters, &Keyword.keyword?/1) do
true ->
processed_filters = Enum.map(filters, &Map.merge(%Filter{}, Enum.into(&1, %{})))
{:ok, processed_filters}
false ->
{:error, "Invalid filter format - all elements must be keyword lists"}
end
_ ->
{:error, "Invalid filter format"}
end
end
def create_filters(_opts), do: {:error, "Invalid filter format"}
@doc """
Create a subscription message with the given filters.
Returns `{:ok, subscription_id, serialized_message}`.
"""
@spec create_subscription_message([Filter.t()]) :: {:ok, String.t(), binary()}
def create_subscription_message(filters) when is_list(filters) do
sub_id = :crypto.strong_rand_bytes(32) |> Base.encode16(case: :lower)
msg =
filters
|> Message.request(sub_id)
|> Message.serialize()
{:ok, sub_id, msg}
end
@doc """
Subscribe to a relay with a specific subscription ID and message.
"""
@spec subscribe(atom(), String.t(), binary()) :: :ok | {:error, String.t()}
def subscribe(relay_name, sub_id, payload) when is_binary(sub_id) do
with :ok <- send_event_serialized(relay_name, payload),
:ok <- RelayAgent.update(relay_name, sub_id) do
:ok
end
end
def subscribe(_, sub_id, _), do: {:error, "invalid sub_id format, got #{sub_id}"}
def sign_event(%Event{} = event, privkey) when is_binary(privkey) do
try do
signed_event = Event.sign(event, privkey)
{:ok, signed_event}
catch
_ -> {:error, "failed to sign event"}
end
end
def sign_event(%Event{} = event, signer_pid) when is_pid(signer_pid) do
case NostrEx.Signer.Local.sign_event(signer_pid, event) do
{:ok, signed_event} ->
{:ok, signed_event}
{:error, reason} ->
{:error, reason}
end
end
def sign_event(%Event{}, signer_or_privkey),
do:
{:error,
"signer must be a binary private key or struct implementing NostrEx.Signer, got: #{inspect(signer_or_privkey)}"}
@doc """
Sign an event with a private key or signer and serialize it as a JSON message.
"""
@spec sign_and_serialize(Event.t(), binary() | struct()) ::
{:ok, binary(), binary()} | {:error, String.t()}
def sign_and_serialize(%Event{} = event, signer_or_privkey) do
case sign_event(event, signer_or_privkey) do
{:ok, signed_event} ->
serialized = serialize(signed_event)
{:ok, signed_event.id, serialized}
err -> err
end
end
def sign_and_serialize(_, _),
do: {:error, "invalid event provided, must be an %Event{} struct."}
def serialize(%Event{} = signed_event) do
signed_event
|> Message.create_event()
|> Message.serialize()
end
@spec get_relays(nil | :all | [atom() | pid() | String.t()]) :: [atom() | pid() | {:error, String.t()}]
defp get_relays(nil), do: get_relays(:all)
defp get_relays(:all), do: RelayManager.registered_names()
defp get_relays([_h | _t] = relay_list) do
{oks, errors} =
relay_list
|> Enum.map(&normalize(&1))
|> Enum.split_with(&is_atom(&1))
Logger.error(Enum.join(errors, ", "))
oks
end
defp get_relays(relay_list) do
Logger.error("invalid relay list provided, got: #{Enum.join(relay_list, ", ")}")
[]
end
defp normalize(relay) do
cond do
relay in RelayManager.registered_names() ->
relay
is_binary(relay) ->
host = relay |> URI.parse() |> Map.get(:host)
with true <- !is_nil(host),
atom_name = Utils.name_from_host(host),
true <- atom_name in RelayManager.registered_names() do
atom_name
else
_ ->
"relay not connected or invalid, got #{relay}"
end
true ->
"invalid relay name, got #{relay}"
end
end
end