skyfall 0.6.1 → 0.7.1

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.
@@ -1,13 +1,34 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative '../errors'
2
4
  require_relative '../jetstream'
5
+ require_relative 'message'
3
6
 
4
7
  module Skyfall
8
+
9
+ #
10
+ # Jetstream message sent when a new DID is created or when the details of someone's DID document
11
+ # are changed (usually either a handle change or a migration to a different PDS). The message
12
+ # should include currently assigned handle (though the field is not required).
13
+ #
14
+ # Note: the message is originally emitted from the account's PDS and is passed as is by relays,
15
+ # which means you can't fully trust that the handle is actually correctly assigned to the DID
16
+ # and verified by DNS or well-known. To confirm that, use `DID.resolve_handle` from
17
+ # [DIDKit](https://ruby.sdk.blue/didkit/).
18
+ #
19
+
5
20
  class Jetstream::IdentityMessage < Jetstream::Message
21
+
22
+ #
23
+ # @param json [Hash] message JSON decoded from the websocket message
24
+ # @raise [DecodeError] if the message doesn't include required data
25
+ #
6
26
  def initialize(json)
7
- raise DecodeError.new("Missing event details") if json['identity'].nil?
27
+ raise DecodeError.new("Missing event details (identity)") if json['identity'].nil?
8
28
  super
9
29
  end
10
30
 
31
+ # @return [String, nil] current handle assigned to the DID
11
32
  def handle
12
33
  @json['identity']['handle']
13
34
  end
@@ -1,22 +1,58 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative '../errors'
2
4
  require_relative '../jetstream'
3
5
 
4
6
  require 'time'
5
7
 
6
8
  module Skyfall
9
+
10
+ # @abstract
11
+ # Abstract base class representing a Jetstream message.
12
+ #
13
+ # Actual messages are returned as instances of one of the subclasses of this class,
14
+ # depending on the type of message, most commonly as {Skyfall::Jetstream::CommitMessage}.
15
+ #
16
+ # The {new} method is overridden here so that it can be called with a JSON message from
17
+ # the websocket, and it parses the type from the JSON and builds an instance of a matching
18
+ # subclass.
19
+ #
20
+ # You normally don't need to call this class directly, unless you're building a custom
21
+ # subclass of {Skyfall::Stream} or reading raw data packets from the websocket through
22
+ # the {Skyfall::Stream#on_raw_message} event handler.
23
+
7
24
  class Jetstream::Message
8
- require_relative 'account_message'
9
- require_relative 'commit_message'
10
- require_relative 'identity_message'
11
- require_relative 'unknown_message'
12
25
 
13
- attr_reader :did, :type, :time_us
26
+ # Type of the message (e.g. `:commit`, `:identity` etc.)
27
+ # @return [Symbol]
28
+ attr_reader :type
29
+
30
+ # DID of the account (repo) that the event is sent by
31
+ # @return [String]
32
+ attr_reader :did
33
+
34
+ # Server timestamp of the message (in Unix time microseconds), which serves as a cursor
35
+ # when reconnecting; an equivalent of {Skyfall::Firehose::Message#seq} in CBOR firehose
36
+ # messages.
37
+ # @return [Integer]
38
+ attr_reader :time_us
39
+
14
40
  alias repo did
15
41
  alias seq time_us
42
+ alias kind type
16
43
 
17
- # :nodoc: - consider this as semi-private API
44
+ # The raw JSON of the message as parsed from the websocket packet.
18
45
  attr_reader :json
19
46
 
47
+ #
48
+ # Parses the JSON data from a websocket message and returns an instance of an appropriate subclass.
49
+ #
50
+ # {Skyfall::Jetstream::UnknownMessage} is returned if the message type is not recognized.
51
+ #
52
+ # @param data [String] plain text payload of a Jetstream websocket message
53
+ # @return [Skyfall::Jetstream::Message]
54
+ # @raise [DecodeError] if the message doesn't include required data
55
+ #
20
56
  def self.new(data)
21
57
  json = JSON.parse(data)
22
58
 
@@ -27,28 +63,79 @@ module Skyfall
27
63
  else Jetstream::UnknownMessage
28
64
  end
29
65
 
66
+ if self != Jetstream::Message && self != message_class
67
+ expected_type = self.name.split('::').last.gsub(/Message$/, '').downcase
68
+ raise DecodeError, "Expected '#{expected_type}' message, got '#{json['kind']}'"
69
+ end
70
+
30
71
  message = message_class.allocate
31
72
  message.send(:initialize, json)
32
73
  message
33
74
  end
34
75
 
76
+ #
77
+ # @param json [Hash] message JSON decoded from the websocket message
78
+ # @raise [DecodeError] if the message doesn't include required data
79
+ #
35
80
  def initialize(json)
81
+ %w(kind did time_us).each { |f| raise DecodeError.new("Missing event details (#{f})") if json[f].nil? }
82
+
36
83
  @json = json
37
84
  @type = @json['kind'].to_sym
38
85
  @did = @json['did']
39
86
  @time_us = @json['time_us']
40
87
  end
41
88
 
89
+ #
90
+ # @return [Boolean] true if the message is {Jetstream::UnknownMessage} (of unrecognized type)
91
+ #
42
92
  def unknown?
43
93
  self.is_a?(Jetstream::UnknownMessage)
44
94
  end
45
95
 
96
+ # Returns a record operation included in the message. Only `:commit` messages include
97
+ # operations, but for convenience the method is declared here and returns nil in other messages.
98
+ #
99
+ # @return [nil]
100
+ #
101
+ def operation
102
+ nil
103
+ end
104
+
105
+ alias op operation
106
+
107
+ # List of operations on records included in the message. Only `:commit` messages include
108
+ # operations, but for convenience the method is declared here and returns an empty array
109
+ # in other messages.
110
+ #
111
+ # @return [Array<Jetstream::Operation>]
112
+ #
46
113
  def operations
47
114
  []
48
115
  end
49
116
 
117
+ #
118
+ # Timestamp decoded from the message.
119
+ #
120
+ # Note: the time is read from the {#time_us} field, which stores the event time as an integer in
121
+ # Unix time microseconds, and which is used as an equivalent of {Skyfall::Firehose::Message#seq}
122
+ # in CBOR firehose messages. This timestamp represents the time when the message was received
123
+ # and stored by Jetstream, which might differ a lot from the `created_at` time saved in the
124
+ # record data, e.g. if user's local time is set incorrectly or if an archive of existing posts
125
+ # was imported from another platform. It will also differ (usually only slightly) from the
126
+ # timestamp of the original CBOR message emitted from the PDS and passed through the relay.
127
+ #
128
+ # @return [Time]
129
+ #
50
130
  def time
51
- @time ||= @json['time_us'] && Time.at(@json['time_us'] / 1_000_000.0)
131
+ @time ||= Time.at(@time_us / 1_000_000.0)
52
132
  end
53
133
  end
54
134
  end
135
+
136
+ # need to be at the end because of a circular dependency
137
+
138
+ require_relative 'account_message'
139
+ require_relative 'commit_message'
140
+ require_relative 'identity_message'
141
+ require_relative 'unknown_message'
@@ -1,58 +1,111 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative '../collection'
2
4
  require_relative '../jetstream'
5
+ require 'oxygene'
3
6
 
4
7
  module Skyfall
8
+
9
+ #
10
+ # A single record operation from a Jetstream commit event. An operation is a new record being
11
+ # created, or an existing record modified or deleted. It includes the URI and other details of
12
+ # the record in question, type of the action taken, and record data for "created" and "update"
13
+ # actions.
14
+ #
15
+ # Note: when a record is deleted, the previous record data is *not* included in the commit, only
16
+ # its URI. This means that if you're tracking records which are referencing other records, e.g.
17
+ # follow, block, or like records, you need to store information about this referencing record
18
+ # including an URI or rkey, because in case of a delete, you will not get information about which
19
+ # post was unliked or which account was unfollowed, only which like/follow record was deleted.
20
+ #
21
+ # At the moment, Skyfall doesn't parse the record data into any rich models specific for a given
22
+ # record type with a convenient API, but simply returns them as `Hash` objects (see {#raw_record}).
23
+ # In the future, a second `#record` method might be added which returns a parsed record model.
24
+ #
25
+
5
26
  class Jetstream::Operation
27
+
28
+ #
29
+ # @param message [Skyfall::Jetstream::Message] commit message the operation is parsed from
30
+ # @param json [Hash] operation data
31
+ #
6
32
  def initialize(message, json)
7
33
  @message = message
8
34
  @json = json
9
35
  end
10
36
 
37
+ # @return [String] DID of the account/repository in which the operation happened
11
38
  def repo
12
39
  @message.repo
13
40
  end
14
41
 
15
42
  alias did repo
16
43
 
44
+ # @return [String] path part of the record URI (collection + rkey)
45
+ # @deprecated Use {#collection} + {#rkey}
17
46
  def path
47
+ @@path_warning_printed ||= false
48
+
49
+ unless @@path_warning_printed
50
+ $stderr.puts "Warning: Skyfall::Jetstream::Operation#path is deprecated - use #collection + #rkey"
51
+ @@path_warning_printed = true
52
+ end
53
+
18
54
  @json['collection'] + '/' + @json['rkey']
19
55
  end
20
56
 
57
+ # @return [Symbol] type of the operation (`:create`, `:update` or `:delete`)
21
58
  def action
22
59
  @json['operation'].to_sym
23
60
  end
24
61
 
62
+ # @return [String] record collection NSID
25
63
  def collection
26
64
  @json['collection']
27
65
  end
28
66
 
67
+ # @return [String] record rkey
29
68
  def rkey
30
69
  @json['rkey']
31
70
  end
32
71
 
72
+ # @return [String] full AT URI of the record
33
73
  def uri
34
74
  "at://#{repo}/#{collection}/#{rkey}"
35
75
  end
36
76
 
77
+ # @return [Oxygene::CID, nil] CID (Content Identifier) of the record (nil for delete operations)
37
78
  def cid
38
- @cid ||= @json['cid'] && CID.from_json(@json['cid'])
79
+ @cid ||= @json['cid'] && Oxygene::CID.from_json(@json['cid'])
39
80
  end
40
81
 
82
+ # @return [Hash, nil] record data as a plain Ruby Hash (nil for delete operations)
41
83
  def raw_record
42
84
  @json['record']
43
85
  end
44
86
 
87
+ # Symbol short code of the collection, like `:bsky_post`. If the collection NSID is not
88
+ # recognized, the type is `:unknown`. The full NSID is always available through the
89
+ # `#collection` property.
90
+ #
91
+ # @return [Symbol]
92
+ # @see Skyfall::Collection
93
+ #
45
94
  def type
46
95
  Collection.short_code(collection)
47
96
  end
48
97
 
49
- def inspectable_variables
50
- instance_variables - [:@message]
51
- end
52
-
98
+ # Returns a string with a representation of the object for debugging purposes.
99
+ # @return [String]
53
100
  def inspect
54
101
  vars = inspectable_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(", ")
55
102
  "#<#{self.class}:0x#{object_id} #{vars}>"
56
103
  end
104
+
105
+ private
106
+
107
+ def inspectable_variables
108
+ instance_variables - [:@message]
109
+ end
57
110
  end
58
111
  end
@@ -1,6 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative '../jetstream'
4
+ require_relative 'message'
2
5
 
3
6
  module Skyfall
7
+
8
+ #
9
+ # Jetstream message of an unrecognized type.
10
+ #
11
+
4
12
  class Jetstream::UnknownMessage < Jetstream::Message
5
13
  end
6
14
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'stream'
2
4
 
3
5
  require 'json'
@@ -5,9 +7,78 @@ require 'time'
5
7
  require 'uri'
6
8
 
7
9
  module Skyfall
10
+
11
+ #
12
+ # Client of a Jetstream service (JSON-based firehose).
13
+ #
14
+ # This is an equivalent of {Skyfall::Firehose} for Jetstream sources, mirroring its API.
15
+ # It returns messages as instances of subclasses of {Skyfall::Jetstream::Message}, which
16
+ # are generally equivalent to the respective {Skyfall::Firehose::Message} variants as much
17
+ # as possible.
18
+ #
19
+ # To connect to a Jetstream websocket, you need to:
20
+ #
21
+ # * create an instance of Jetstream, passing it the hostname/URL of the server, and optionally
22
+ # parameters such as cursor or collection/DID filters
23
+ # * set up callbacks to be run when connecting, disconnecting, when a message is received etc.
24
+ # (you need to set at least a message handler)
25
+ # * call {#connect} to start the connection
26
+ # * handle the received messages
27
+ #
28
+ # @example
29
+ # client = Skyfall::Jetstream.new('jetstream2.us-east.bsky.network', {
30
+ # wanted_collections: 'app.bsky.feed.post',
31
+ # wanted_dids: @dids
32
+ # })
33
+ #
34
+ # client.on_message do |msg|
35
+ # next unless msg.type == :commit
36
+ #
37
+ # op = msg.operation
38
+ #
39
+ # if op.type == :bsky_post && op.action == :create
40
+ # puts "[#{msg.time}] #{msg.repo}: #{op.raw_record['text']}"
41
+ # end
42
+ # end
43
+ #
44
+ # client.connect
45
+ #
46
+ # # You might also want to set some or all of these lifecycle callback handlers:
47
+ #
48
+ # client.on_connecting { |url| puts "Connecting to #{url}..." }
49
+ # client.on_connect { puts "Connected" }
50
+ # client.on_disconnect { puts "Disconnected" }
51
+ # client.on_reconnect { puts "Connection lost, trying to reconnect..." }
52
+ # client.on_timeout { puts "Connection stalled, triggering a reconnect..." }
53
+ # client.on_error { |e| puts "ERROR: #{e}" }
54
+ #
55
+ # @note Most of the methods of this class that you might want to use are defined in {Skyfall::Stream}.
56
+ #
57
+
8
58
  class Jetstream < Stream
59
+
60
+ # Current cursor (time of the last seen message)
61
+ # @return [Integer, nil]
9
62
  attr_accessor :cursor
10
63
 
64
+ #
65
+ # @param server [String] Address of the server to connect to.
66
+ # Expects a string with either just a hostname, or a ws:// or wss:// URL with no path.
67
+ # @param params [Hash] options, see below:
68
+ #
69
+ # @option params [Integer] :cursor
70
+ # cursor from which to resume
71
+ #
72
+ # @option params [Array<String>] :wanted_dids
73
+ # DID filter to pass to the server (`:wantedDids` is also accepted);
74
+ # value should be a DID string or an array of those
75
+ #
76
+ # @option params [Array<String, Symbol>] :wanted_collections
77
+ # collection filter to pass to the server (`:wantedCollections` is also accepted);
78
+ # value should be an NSID string or a symbol shorthand, or an array of those
79
+ #
80
+ # @raise [ArgumentError] if the server parameter or the options are invalid
81
+ #
11
82
  def initialize(server, params = {})
12
83
  require_relative 'jetstream/message'
13
84
  super(server)
@@ -17,6 +88,28 @@ module Skyfall
17
88
  @root_url = ensure_empty_path(@root_url)
18
89
  end
19
90
 
91
+
92
+ protected
93
+
94
+ # Returns the full URL of the websocket endpoint to connect to.
95
+ # @return [String]
96
+
97
+ def build_websocket_url
98
+ params = @cursor ? @params.merge(cursor: @cursor) : @params
99
+ query = URI.encode_www_form(params)
100
+
101
+ @root_url + "/subscribe" + (query.length > 0 ? "?#{query}" : '')
102
+ end
103
+
104
+ # Processes a single message received from the websocket. Passes the received data to the
105
+ # {#on_raw_message} handler, builds a {Skyfall::Jetstream::Message} object, and passes it to
106
+ # the {#on_message} handler (if defined). Also updates the {#cursor} to this message's
107
+ # microsecond timestamp (note: this is skipped if {#on_message} is not set).
108
+ #
109
+ # @param msg
110
+ # {https://rubydoc.info/gems/faye-websocket/Faye/WebSocket/API/MessageEvent Faye::WebSocket::API::MessageEvent}
111
+ # @return [nil]
112
+
20
113
  def handle_message(msg)
21
114
  data = msg.data
22
115
  @handlers[:raw_message]&.call(data)
@@ -30,14 +123,8 @@ module Skyfall
30
123
  end
31
124
  end
32
125
 
33
- private
34
126
 
35
- def build_websocket_url
36
- params = @cursor ? @params.merge(cursor: @cursor) : @params
37
- query = URI.encode_www_form(params)
38
-
39
- @root_url + "/subscribe" + (query.length > 0 ? "?#{query}" : '')
40
- end
127
+ private
41
128
 
42
129
  def check_params(params)
43
130
  params ||= {}
data/lib/skyfall/label.rb CHANGED
@@ -1,10 +1,34 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'errors'
4
+ require 'oxygene'
2
5
  require 'time'
3
6
 
4
7
  module Skyfall
8
+
9
+ #
10
+ # A single label emitted from the "subscribeLabels" firehose of a labeller service.
11
+ #
12
+ # The label assigns some specific value - from a list of available values defined by this
13
+ # labeller - to a specific target (at:// URI or a DID). In general, this will usually be either
14
+ # a "badge" that a user requested to be assigned to themselves from a fun/informative labeller,
15
+ # or some kind of (likely negative) label assigned to a user or post by a moderation labeller.
16
+ #
17
+ # You generally don't need to create instances of this class manually, but will receive them
18
+ # from {Skyfall::Firehose} that's connected to `:subscribe_labels` in the {Stream#on_message}
19
+ # callback handler (wrapped in a {Skyfall::Firehose::LabelsMessage}).
20
+ #
21
+
5
22
  class Label
23
+
24
+ # @return [Hash] the label's JSON data
6
25
  attr_reader :data
7
26
 
27
+ #
28
+ # @param data [Hash] raw label JSON
29
+ # @raise [Skyfall::DecodeError] if the data has an invalid format
30
+ # @raise [Skyfall::UnsupportedError] if the label is in an unsupported future version
31
+ #
8
32
  def initialize(data)
9
33
  @data = data
10
34
 
@@ -20,34 +44,44 @@ module Skyfall
20
44
  raise DecodeError.new("Invalid uri: #{uri}") unless uri.start_with?('at://') || uri.start_with?('did:')
21
45
  end
22
46
 
47
+ # @return [Integer] label format version number
23
48
  def version
24
49
  @data['ver']
25
50
  end
26
51
 
52
+ # DID of the labelling authority (the labeller service).
53
+ # @return [String]
27
54
  def authority
28
55
  @data['src']
29
56
  end
30
57
 
58
+ # AT URI or DID of the labelled subject (e.g. a user or post).
59
+ # @return [String]
31
60
  def subject
32
61
  @data['uri']
33
62
  end
34
63
 
64
+ # @return [Oxygene::CID, nil] CID of the specific version of the subject that this label applies to
35
65
  def cid
36
- @cid ||= @data['cid'] && CID.from_json(@data['cid'])
66
+ @cid ||= @data['cid'] && Oxygene::CID.from_json(@data['cid'])
37
67
  end
38
68
 
69
+ # @return [String] label value
39
70
  def value
40
71
  @data['val']
41
72
  end
42
73
 
74
+ # @return [Boolean] if true, then this is a negation (delete) of an existing label
43
75
  def negation?
44
76
  !!@data['neg']
45
77
  end
46
78
 
79
+ # @return [Time] timestamp when the label was created
47
80
  def created_at
48
81
  @created_at ||= Time.parse(@data['cts'])
49
82
  end
50
83
 
84
+ # @return [Time, nil] optional timestamp when the label expires
51
85
  def expires_at
52
86
  @expires_at ||= @data['exp'] && Time.parse(@data['exp'])
53
87
  end