lib/nostr/client.rb
# frozen_string_literal: true
require 'event_emitter'
require 'faye/websocket'
module Nostr
# Clients can talk with relays and can subscribe to any set of events using a subscription filters.
# The filter represents all the set of nostr events that a client is interested in.
#
# There is no sign-up or account creation for a client. Every time a client connects to a relay, it submits its
# subscription filters and the relay streams the "interested events" to the client as long as they are connected.
#
class Client
include EventEmitter
# Instantiates a new Client
#
# @api public
#
# @example Instantiating a client that logs all the events it sends and receives
# client = Nostr::Client.new
#
# @example Instantiating a client with no logging
# client = Nostr::Client.new(logger: nil)
#
# @example Instantiating a client with your own logger
# client = Nostr::Client.new(logger: YourLogger.new)
#
def initialize(logger: ColorLogger.new)
@subscriptions = {}
@logger = logger
logger&.attach_to(self)
initialize_channels
end
# Connects to the Relay's websocket endpoint
#
# @api public
#
# @example Connecting to a relay
# relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus')
# client.connect(relay)
#
# @param [Relay] relay The relay to connect to
#
# @return [void]
#
def connect(relay)
execute_within_an_em_thread do
client = build_websocket_client(relay.url)
parent_to_child_channel.subscribe { |msg| client.send(msg) && emit(:send, msg) }
client.on :open do
child_to_parent_channel.push(type: :open, relay:)
end
client.on :message do |event|
child_to_parent_channel.push(type: :message, data: event.data)
end
client.on :error do |event|
child_to_parent_channel.push(type: :error, message: event.message)
end
client.on :close do |event|
child_to_parent_channel.push(type: :close, code: event.code, reason: event.reason)
end
end
end
# Subscribes to a set of events using a filter
#
# @api public
#
# @example Creating a subscription with no id and no filters
# subscription = client.subscribe
#
# @example Creating a subscription with an ID
# subscription = client.subscribe(subscription_id: 'my-subscription')
#
# @example Subscribing to all events created after a certain time
# subscription = client.subscribe(filter: Nostr::Filter.new(since: 1230981305))
#
# @param subscription_id [String] The subscription id. An arbitrary, non-empty string of max length 64
# chars used to represent a subscription.
# @param filter [Filter] A set of attributes that represent the events that the client is interested in.
#
# @return [Subscription] The subscription object
#
def subscribe(subscription_id: SecureRandom.hex, filter: Filter.new)
subscriptions[subscription_id] = Subscription.new(id: subscription_id, filter:)
parent_to_child_channel.push([ClientMessageType::REQ, subscription_id, filter.to_h].to_json)
subscriptions[subscription_id]
end
# Stops a previous subscription
#
# @api public
#
# @example Stopping a subscription
# client.unsubscribe(subscription.id)
#
# @example Stopping a subscription
# client.unsubscribe('my-subscription')
#
# @param subscription_id [String] ID of a previously created subscription.
#
# @return [void]
#
def unsubscribe(subscription_id)
subscriptions.delete(subscription_id)
parent_to_child_channel.push([ClientMessageType::CLOSE, subscription_id].to_json)
end
# Sends an event to a Relay
#
# @api public
#
# @example Sending an event to a relay
# client.publish(event)
#
# @param event [Event] The event to be sent to a Relay
#
# @return [void]
#
def publish(event)
parent_to_child_channel.push([ClientMessageType::EVENT, event.to_h].to_json)
end
private
# The logger that prints all the events that the client sends and receives
#
# @api private
#
# @return [ClientLogger]
#
attr_reader :logger
# The subscriptions that the client has created
#
# @api private
#
# @return [Hash{String=>Subscription}>]
#
attr_reader :subscriptions
# The channel that the parent thread uses to send messages to the child thread
#
# @api private
#
# @return [EventMachine::Channel]
#
attr_reader :parent_to_child_channel
# The channel that the child thread uses to send messages to the parent thread
#
# @api private
#
# @return [EventMachine::Channel]
#
attr_reader :child_to_parent_channel
# Executes a block of code within the EventMachine thread
#
# @api private
#
# @return [Thread]
#
def execute_within_an_em_thread(&block)
Thread.new { EventMachine.run(block) }
end
# Creates the communication channels between threads
#
# @api private
#
# @return [void]
#
def initialize_channels
@parent_to_child_channel = EventMachine::Channel.new
@child_to_parent_channel = EventMachine::Channel.new
child_to_parent_channel.subscribe do |msg|
emit :connect, msg[:relay] if msg[:type] == :open
emit :message, msg[:data] if msg[:type] == :message
emit :error, msg[:message] if msg[:type] == :error
emit :close, msg[:code], msg[:reason] if msg[:type] == :close
end
end
# Builds a websocket client
#
# @api private
#
# @return [Faye::WebSocket::Client]
#
def build_websocket_client(relay_url)
Faye::WebSocket::Client.new(relay_url, [], { tls: { verify_peer: false } })
end
end
end