nostr_ruby 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/client.rb ADDED
@@ -0,0 +1,184 @@
1
+ require_relative 'event_wizard'
2
+ require 'faye/websocket'
3
+
4
+ module Nostr
5
+
6
+ class Client
7
+ include EventWizard
8
+
9
+ attr_reader :signer
10
+ attr_reader :relay
11
+ attr_reader :subscriptions
12
+
13
+
14
+ def initialize(signer: nil, private_key: nil, relay: nil, context: Context.new(timeout: 5))
15
+ initialize_event_emitter
16
+
17
+ if signer
18
+ @signer = signer
19
+ elsif private_key
20
+ @signer = Nostr::Signer.new(private_key: private_key)
21
+ end
22
+
23
+ @relay = relay
24
+ @context = context
25
+
26
+ @running = false
27
+ @expected_response_id = nil
28
+ @response_condition = ConditionVariable.new
29
+ @response_mutex = Mutex.new
30
+ @event_to_publish = nil
31
+
32
+ @subscriptions = {}
33
+ @outbound_channel = EventMachine::Channel.new
34
+ @inbound_channel = EventMachine::Channel.new
35
+
36
+ @inbound_channel.subscribe do |msg|
37
+ case msg[:type]
38
+ when :open
39
+ emit :connect, msg[:relay]
40
+ when :message
41
+ parsed_data = Nostr::MessageHandler.handle(msg[:data])
42
+ emit :message, parsed_data
43
+ emit :event, parsed_data if parsed_data.type == "EVENT"
44
+ emit :ok, parsed_data if parsed_data.type == "OK"
45
+ emit :eose, parsed_data if parsed_data.type == "EOSE"
46
+ emit :closed, parsed_data if parsed_data.type == "CLOSED"
47
+ emit :notice, parsed_data if parsed_data.type == "NOTICE"
48
+ when :error
49
+ emit :error, msg[:message]
50
+ when :close
51
+ emit :close, msg[:code], msg[:reason]
52
+ end
53
+ end
54
+ end
55
+
56
+ def nsec
57
+ signer.nsec
58
+ end
59
+
60
+ def private_key
61
+ signer.private_key
62
+ end
63
+
64
+ def npub
65
+ signer.npub
66
+ end
67
+
68
+ def public_key
69
+ signer.public_key
70
+ end
71
+
72
+ def sign(event)
73
+ signer.sign(event)
74
+ end
75
+
76
+ def decrypt(event)
77
+ signer.decrypt(event)
78
+ end
79
+
80
+ def generate_delegation_tag(to:, conditions:)
81
+ signer.generate_delegation_tag(to, conditions)
82
+ end
83
+
84
+ def connect(context: @context)
85
+ @thread = Thread.new do
86
+ EM.run do
87
+ @ws_client = Faye::WebSocket::Client.new(@relay)
88
+
89
+ @outbound_channel.subscribe { |msg| @ws_client.send(msg) && emit(:send, msg) }
90
+
91
+ @ws_client.on :open do
92
+ @running = true
93
+ @inbound_channel.push(type: :open, relay: @relay)
94
+ end
95
+
96
+ @ws_client.on :message do |event|
97
+ @inbound_channel.push(type: :message, data: event.data)
98
+ end
99
+
100
+ @ws_client.on :error do |event|
101
+ @inbound_channel.push(type: :error, message: event.message)
102
+ end
103
+
104
+ @ws_client.on :close do |event|
105
+ context.cancel
106
+ @inbound_channel.push(type: :close, code: event.code, reason: event.reason)
107
+ end
108
+
109
+ end
110
+ end
111
+
112
+ # Wait for the connection to be established or for the context to be canceled
113
+ if context
114
+ context.wait { @running }
115
+ end
116
+
117
+ end
118
+
119
+ def running?
120
+ @running
121
+ end
122
+
123
+ def close
124
+ @running = false
125
+ EM.next_tick do
126
+ @ws_client.close if @ws_client
127
+ EM.add_timer(0.1) do
128
+ EM.stop if EM.reactor_running?
129
+ end
130
+ end
131
+ end
132
+
133
+ def publish(event)
134
+ return false unless running?
135
+ @outbound_channel.push(['EVENT', event.to_json].to_json)
136
+ return true
137
+ end
138
+
139
+ def publish_and_wait(event, context: @context, close_on_finish: false)
140
+ return false unless running?
141
+
142
+ response = nil
143
+ @outbound_channel.push(['EVENT', event.to_json].to_json)
144
+
145
+ response_thread = Thread.new do
146
+ context.wait do
147
+ @response_mutex.synchronize do
148
+ @response_condition.wait(@response_mutex) # Wait for a response
149
+ end
150
+ end
151
+ end
152
+
153
+ @inbound_channel.subscribe do |message|
154
+ parsed_data = Nostr::MessageHandler.handle(message[:data])
155
+ if parsed_data.type == "OK" && parsed_data.event_id == event.id
156
+ response = parsed_data
157
+ @response_condition.signal
158
+ end
159
+ end
160
+
161
+ response_thread.join
162
+ close if close_on_finish
163
+
164
+ response
165
+ end
166
+
167
+ def subscribe(subscription_id: SecureRandom.hex, filter: Filter.new)
168
+ @subscriptions[subscription_id] = filter
169
+ @outbound_channel.push(["REQ", subscription_id, filter.to_h].to_json)
170
+ @subscriptions[subscription_id]
171
+ subscription_id
172
+ end
173
+
174
+ def unsubscribe(subscription_id)
175
+ @subscriptions.delete(subscription_id)
176
+ @outbound_channel.push(["CLOSE", subscription_id].to_json)
177
+ end
178
+
179
+ def unsubscribe_all
180
+ @subscriptions.each{|s| unsubscribe(s[0])}
181
+ end
182
+
183
+ end
184
+ end
data/lib/context.rb ADDED
@@ -0,0 +1,48 @@
1
+ class Context
2
+ attr_reader :canceled, :timeout
3
+
4
+ def initialize(timeout: nil)
5
+ @timeout = timeout
6
+ @canceled = false
7
+ @mutex = Mutex.new
8
+ @condition = ConditionVariable.new
9
+
10
+ # Start a timer if a timeout is specified
11
+ if @timeout
12
+ @start_time = Time.now
13
+ end
14
+ end
15
+
16
+ def cancel
17
+ @mutex.synchronize do
18
+ @canceled = true
19
+ @condition.broadcast
20
+ end
21
+ end
22
+
23
+ def timed_out?
24
+ return false unless @timeout
25
+
26
+ # Check the elapsed time without locking the mutex
27
+ Time.now - @start_time > @timeout
28
+ end
29
+
30
+ def wait(&block)
31
+ reset # Reset the context state before waiting
32
+ loop do
33
+ break if block.call
34
+ if timed_out?
35
+ raise StandardError.new("Operation timed out after #{timeout} seconds")
36
+ end
37
+ sleep(0.1) # Sleep briefly to avoid busy-waiting
38
+ end
39
+ end
40
+
41
+ def reset
42
+ @mutex.synchronize do
43
+ @canceled = false
44
+ @start_time = Time.now if @timeout
45
+ end
46
+ end
47
+
48
+ end
data/lib/event.rb ADDED
@@ -0,0 +1,188 @@
1
+ module Nostr
2
+ class Event
3
+ include CryptoTools
4
+
5
+ ATTRIBUTES = [:kind, :pubkey, :created_at, :tags, :content, :id, :sig, :pow, :delegation, :recipient]
6
+
7
+ # Create attr_reader for each attribute name
8
+ ATTRIBUTES.each do |attribute|
9
+ attr_reader attribute
10
+ end
11
+
12
+ attr_reader :errors
13
+
14
+ class ValidationError < StandardError; end
15
+
16
+ def initialize(
17
+ kind:,
18
+ pubkey: nil,
19
+ created_at: nil,
20
+ tags: [],
21
+ content: nil,
22
+ id: nil,
23
+ sig: nil,
24
+ pow: nil,
25
+ delegation: nil,
26
+ subscription_id: nil
27
+ )
28
+ @pubkey = pubkey
29
+ @created_at = created_at ? created_at : Time.now.utc.to_i
30
+ @kind = kind
31
+ @tags = tags
32
+ @content = content
33
+ @id = id
34
+ @sig = sig
35
+
36
+ @pow = pow
37
+ @delegation = delegation
38
+
39
+ end
40
+
41
+ # Create setter methods for each attribute name
42
+ ATTRIBUTES.each do |attribute|
43
+ define_method("#{attribute}=") do |value|
44
+ return if instance_variable_get("@#{attribute}") == value
45
+ instance_variable_set("@#{attribute}", value)
46
+ reset! unless attribute == :id || attribute == :sig
47
+ end
48
+ end
49
+
50
+ def type
51
+ "EVENT"
52
+ end
53
+
54
+ def content=(content)
55
+ return if @content == content
56
+ @content = content
57
+ reset!
58
+ end
59
+
60
+ def has_tag?(tag)
61
+ @tags.each_slice(2).any? { |e| e.first == tag }
62
+ end
63
+
64
+ def to_json
65
+ {
66
+ 'kind': @kind,
67
+ 'pubkey': @pubkey,
68
+ 'created_at': @created_at,
69
+ 'tags': @tags,
70
+ 'content': @content,
71
+ 'id': @id,
72
+ 'sig': @sig,
73
+ }
74
+ end
75
+
76
+ def match_pow_difficulty?
77
+ self.match_pow_difficulty?(@id, pow)
78
+ end
79
+
80
+ def self.match_pow_difficulty?(event_id, pow)
81
+ pow.nil? || pow == [event_id].pack("H*").unpack("B*")[0].index('1')
82
+ end
83
+
84
+ def serialize
85
+ [
86
+ 0,
87
+ @pubkey,
88
+ @created_at,
89
+ @kind,
90
+ @tags,
91
+ @content
92
+ ]
93
+ end
94
+
95
+ def signable?
96
+ @errors = []
97
+
98
+ # Check mandatory fields
99
+ @errors << "Kind is missing" if @kind.nil?
100
+ @errors << "Created at is missing" if @created_at.nil?
101
+
102
+ # Type validations
103
+ @errors << "Pubkey must be a string" if @pubkey && !@pubkey.is_a?(String)
104
+ @errors << "Kind must be an integer" unless @kind.is_a?(Integer)
105
+ if @created_at
106
+ # Check if it's a valid Unix timestamp or can be converted to one
107
+ begin
108
+ timestamp = if @created_at.is_a?(Time)
109
+ @created_at.to_i
110
+ elsif @created_at.is_a?(Integer)
111
+ @created_at
112
+ elsif @created_at.respond_to?(:to_time)
113
+ @created_at.to_time.to_i
114
+ else
115
+ raise ArgumentError
116
+ end
117
+
118
+ # Validate timestamp range
119
+ @errors << "Created at is not a valid timestamp" unless
120
+ timestamp.is_a?(Integer) &&
121
+ timestamp >= 0
122
+ rescue
123
+ @errors << "Created at must be a valid datetime or Unix timestamp"
124
+ end
125
+ end
126
+ @errors << "Tags must be an array" unless @tags.is_a?(Array)
127
+
128
+ @errors << "Content must be a string" if @content && !@content.is_a?(String)
129
+ @errors << "ID must be a string" if @id && !@id.is_a?(String)
130
+ @errors << "Signature must be a string" if @sig && !@sig.is_a?(String)
131
+ @errors << "POW must be an integer" if @pow && !@pow.is_a?(Integer)
132
+ @errors << "Delegation must be an array" if @delegation && !@delegation.is_a?(Array)
133
+
134
+ if @errors.any?
135
+ raise ValidationError, @errors.join(", ")
136
+ end
137
+
138
+ true
139
+ end
140
+
141
+ def valid?
142
+ begin
143
+ signable?
144
+ rescue ValidationError => e
145
+ return false
146
+ end
147
+
148
+ # Additional checks for a valid signed event
149
+ @errors = []
150
+ @errors << "ID is missing" if @id.nil?
151
+ @errors << "Signature is missing" if @sig.nil?
152
+ @errors << "Pubkey is missing" if @pubkey.nil?
153
+
154
+ if @errors.any?
155
+ raise ValidationError, @errors.join(", ")
156
+ end
157
+
158
+ true
159
+ end
160
+
161
+ def self.from_message(message)
162
+ subscription_id = message[1]
163
+ event_data = message[2]
164
+
165
+ event = new(
166
+ subscription_id: subscription_id,
167
+ kind: event_data["kind"],
168
+ pubkey: event_data["pubkey"],
169
+ created_at: event_data["created_at"],
170
+ tags: event_data["tags"],
171
+ content: event_data["content"],
172
+ id: event_data["id"],
173
+ sig: event_data["sig"],
174
+ pow: event_data["nonce"]&.last&.to_i
175
+ )
176
+ raise ArgumentError, "Event is not valid" unless event.valid?
177
+ return event
178
+ end
179
+
180
+ private
181
+
182
+ def reset!
183
+ @id = nil
184
+ @sign = nil
185
+ end
186
+
187
+ end
188
+ end
@@ -0,0 +1,31 @@
1
+ module EventWizard
2
+ def initialize_event_emitter
3
+ @listeners = Hash.new { |hash, key| hash[key] = [] }
4
+ end
5
+
6
+ def on(event, &callback)
7
+ # Prevent adding the same callback multiple times
8
+ unless @listeners[event].include?(callback)
9
+ @listeners[event] << callback
10
+ end
11
+ end
12
+
13
+ def emit(event, *args)
14
+ @listeners[event].each { |callback| callback.call(*args) }
15
+ end
16
+
17
+ def off(event, callback)
18
+ return unless @listeners[event]
19
+ @listeners[event].delete(callback)
20
+ end
21
+
22
+ def replace(event, old_callback, new_callback)
23
+ return unless @listeners[event]
24
+ index = @listeners[event].index(old_callback)
25
+ @listeners[event][index] = new_callback if index
26
+ end
27
+
28
+ def clear(event)
29
+ @listeners.delete(event)
30
+ end
31
+ end
data/lib/filter.rb ADDED
@@ -0,0 +1,57 @@
1
+ module Nostr
2
+ class Filter
3
+
4
+ attr_reader :id
5
+ attr_reader :kinds
6
+ attr_reader :authors
7
+ attr_reader :tags
8
+ attr_reader :since
9
+ attr_reader :until
10
+ attr_reader :limit
11
+ attr_reader :search
12
+ ('a'..'z').each { |char| attr_reader char.to_sym }
13
+ ('A'..'Z').each { |char| attr_reader char.to_sym }
14
+
15
+ def initialize(ids: nil, kinds: nil, authors: nil, since: nil, limit: nil, search: nil, **params)
16
+ @id = id
17
+ @kinds = kinds
18
+ @authors = authors
19
+ @tags = tags
20
+ @since = since.nil? ? nil : since.to_i
21
+ @limit = limit
22
+ @search = search
23
+ @until = params[:until].nil? ? nil : params[:until].to_i # this is an hack to permit the use of the 'until' param, since it is a reserved word
24
+
25
+ # Handle additional parameters with a-zA-Z names
26
+ params.each do |key, value|
27
+ if key.to_s.match?(/\A[a-zA-Z]\z/)
28
+ instance_variable_set("@#{key}", value)
29
+ end
30
+ end
31
+ end
32
+
33
+ def to_h
34
+ result = {
35
+ id: @id,
36
+ authors: @authors,
37
+ kinds: @kinds,
38
+ since: @since,
39
+ until: @until,
40
+ limit: @limit,
41
+ search: @search
42
+ }.compact
43
+
44
+ ('a'..'z').each do |char|
45
+ var_value = instance_variable_get("@#{char}")
46
+ result["##{char}"] = var_value unless var_value.nil?
47
+ end
48
+ ('A'..'Z').each do |char|
49
+ var_value = instance_variable_get("@#{char}")
50
+ result["##{char}"] = var_value unless var_value.nil?
51
+ end
52
+
53
+ result
54
+ end
55
+
56
+ end
57
+ end
data/lib/key.rb ADDED
@@ -0,0 +1,15 @@
1
+ module Nostr
2
+ class Key
3
+
4
+ def self.generate_private_key
5
+ group = ECDSA::Group::Secp256k1
6
+ (1 + SecureRandom.random_number(group.order - 1)).to_s(16).rjust(64, '0')
7
+ end
8
+
9
+ def self.get_public_key(private_key)
10
+ group = ECDSA::Group::Secp256k1
11
+ group.generator.multiply_by_scalar(private_key.to_i(16)).x.to_s(16).rjust(64, '0')
12
+ end
13
+
14
+ end
15
+ end
data/lib/kind.rb ADDED
@@ -0,0 +1,10 @@
1
+ module Nostr
2
+ module Kind
3
+ METADATA = 0
4
+ SHORT_NOTE = 1
5
+ CONTACT_LIST = 3
6
+ DIRECT_MESSAGE = 4
7
+ DELETION = 5
8
+ REACTION = 7
9
+ end
10
+ end
@@ -0,0 +1,107 @@
1
+ module Nostr
2
+ class MessageHandler
3
+ def self.handle(message)
4
+
5
+ message = JSON.parse(message) rescue ["?", message]
6
+ type = message[0]
7
+ strategy_class = case type
8
+ when 'EVENT' then EventMessageStrategy
9
+ when 'OK' then OkMessageStrategy
10
+ when 'EOSE' then EoseMessageStrategy
11
+ when 'CLOSED' then ClosedMessageStrategy
12
+ when 'NOTICE' then NoticeMessageStrategy
13
+ else UnknownMessageStrategy
14
+ end
15
+
16
+ processed_data = strategy_class.new(message).process
17
+ type == "EVENT" ? processed_data : ParsedData.new(processed_data)
18
+
19
+ end
20
+ end
21
+
22
+ class BaseMessageStrategy
23
+ def initialize(message)
24
+ @message = message
25
+ end
26
+
27
+ def process
28
+ raise NotImplementedError
29
+ end
30
+ end
31
+
32
+ class EventMessageStrategy < BaseMessageStrategy
33
+ def process
34
+ Event.from_message(@message)
35
+ end
36
+ end
37
+
38
+ class OkMessageStrategy < BaseMessageStrategy
39
+ def process
40
+ {
41
+ type: 'OK',
42
+ event_id: @message[1],
43
+ success: @message[2],
44
+ message: @message[3]
45
+ }
46
+ end
47
+ end
48
+
49
+ class EoseMessageStrategy < BaseMessageStrategy
50
+ def process
51
+ {
52
+ type: 'EOSE',
53
+ subscription_id: @message[1]
54
+ }
55
+ end
56
+ end
57
+
58
+ class ClosedMessageStrategy < BaseMessageStrategy
59
+ def process
60
+ {
61
+ type: 'CLOSED',
62
+ subscription_id: @message[1],
63
+ reason: @message[2]
64
+ }
65
+ end
66
+ end
67
+
68
+ class NoticeMessageStrategy < BaseMessageStrategy
69
+ def process
70
+ {
71
+ type: 'NOTICE',
72
+ message: @message[1]
73
+ }
74
+ end
75
+ end
76
+
77
+ class UnknownMessageStrategy < BaseMessageStrategy
78
+ def process
79
+ {
80
+ type: 'UNKNOWN',
81
+ raw_message: @message
82
+ }
83
+ end
84
+ end
85
+ end
86
+
87
+ class ParsedData
88
+ def initialize(data)
89
+ @data = data
90
+ end
91
+
92
+ def type
93
+ @data[:type]
94
+ end
95
+
96
+ def method_missing(method_name, *args, &block)
97
+ if @data.key?(method_name)
98
+ @data[method_name]
99
+ else
100
+ super
101
+ end
102
+ end
103
+
104
+ def respond_to_missing?(method_name, include_private = false)
105
+ @data.key?(method_name) || super
106
+ end
107
+ end