skyfall 0.6.1 → 0.7.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.
@@ -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,110 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative '../collection'
2
4
  require_relative '../jetstream'
3
5
 
4
6
  module Skyfall
7
+
8
+ #
9
+ # A single record operation from a Jetstream commit event. An operation is a new record being
10
+ # created, or an existing record modified or deleted. It includes the URI and other details of
11
+ # the record in question, type of the action taken, and record data for "created" and "update"
12
+ # actions.
13
+ #
14
+ # Note: when a record is deleted, the previous record data is *not* included in the commit, only
15
+ # its URI. This means that if you're tracking records which are referencing other records, e.g.
16
+ # follow, block, or like records, you need to store information about this referencing record
17
+ # including an URI or rkey, because in case of a delete, you will not get information about which
18
+ # post was unliked or which account was unfollowed, only which like/follow record was deleted.
19
+ #
20
+ # At the moment, Skyfall doesn't parse the record data into any rich models specific for a given
21
+ # record type with a convenient API, but simply returns them as `Hash` objects (see {#raw_record}).
22
+ # In the future, a second `#record` method might be added which returns a parsed record model.
23
+ #
24
+
5
25
  class Jetstream::Operation
26
+
27
+ #
28
+ # @param message [Skyfall::Jetstream::Message] commit message the operation is parsed from
29
+ # @param json [Hash] operation data
30
+ #
6
31
  def initialize(message, json)
7
32
  @message = message
8
33
  @json = json
9
34
  end
10
35
 
36
+ # @return [String] DID of the account/repository in which the operation happened
11
37
  def repo
12
38
  @message.repo
13
39
  end
14
40
 
15
41
  alias did repo
16
42
 
43
+ # @return [String] path part of the record URI (collection + rkey)
44
+ # @deprecated Use {#collection} + {#rkey}
17
45
  def path
46
+ @@path_warning_printed ||= false
47
+
48
+ unless @@path_warning_printed
49
+ $stderr.puts "Warning: Skyfall::Jetstream::Operation#path is deprecated - use #collection + #rkey"
50
+ @@path_warning_printed = true
51
+ end
52
+
18
53
  @json['collection'] + '/' + @json['rkey']
19
54
  end
20
55
 
56
+ # @return [Symbol] type of the operation (`:create`, `:update` or `:delete`)
21
57
  def action
22
58
  @json['operation'].to_sym
23
59
  end
24
60
 
61
+ # @return [String] record collection NSID
25
62
  def collection
26
63
  @json['collection']
27
64
  end
28
65
 
66
+ # @return [String] record rkey
29
67
  def rkey
30
68
  @json['rkey']
31
69
  end
32
70
 
71
+ # @return [String] full AT URI of the record
33
72
  def uri
34
73
  "at://#{repo}/#{collection}/#{rkey}"
35
74
  end
36
75
 
76
+ # @return [CID, nil] CID (Content Identifier) of the record (nil for delete operations)
37
77
  def cid
38
78
  @cid ||= @json['cid'] && CID.from_json(@json['cid'])
39
79
  end
40
80
 
81
+ # @return [Hash, nil] record data as a plain Ruby Hash (nil for delete operations)
41
82
  def raw_record
42
83
  @json['record']
43
84
  end
44
85
 
86
+ # Symbol short code of the collection, like `:bsky_post`. If the collection NSID is not
87
+ # recognized, the type is `:unknown`. The full NSID is always available through the
88
+ # `#collection` property.
89
+ #
90
+ # @return [Symbol]
91
+ # @see Skyfall::Collection
92
+ #
45
93
  def type
46
94
  Collection.short_code(collection)
47
95
  end
48
96
 
49
- def inspectable_variables
50
- instance_variables - [:@message]
51
- end
52
-
97
+ # Returns a string with a representation of the object for debugging purposes.
98
+ # @return [String]
53
99
  def inspect
54
100
  vars = inspectable_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(", ")
55
101
  "#<#{self.class}:0x#{object_id} #{vars}>"
56
102
  end
103
+
104
+ private
105
+
106
+ def inspectable_variables
107
+ instance_variables - [:@message]
108
+ end
57
109
  end
58
110
  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,33 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'errors'
2
4
  require 'time'
3
5
 
4
6
  module Skyfall
7
+
8
+ #
9
+ # A single label emitted from the "subscribeLabels" firehose of a labeller service.
10
+ #
11
+ # The label assigns some specific value - from a list of available values defined by this
12
+ # labeller - to a specific target (at:// URI or a DID). In general, this will usually be either
13
+ # a "badge" that a user requested to be assigned to themselves from a fun/informative labeller,
14
+ # or some kind of (likely negative) label assigned to a user or post by a moderation labeller.
15
+ #
16
+ # You generally don't need to create instances of this class manually, but will receive them
17
+ # from {Skyfall::Firehose} that's connected to `:subscribe_labels` in the {Stream#on_message}
18
+ # callback handler (wrapped in a {Skyfall::Firehose::LabelsMessage}).
19
+ #
20
+
5
21
  class Label
22
+
23
+ # @return [Hash] the label's JSON data
6
24
  attr_reader :data
7
25
 
26
+ #
27
+ # @param data [Hash] raw label JSON
28
+ # @raise [Skyfall::DecodeError] if the data has an invalid format
29
+ # @raise [Skyfall::UnsupportedError] if the label is in an unsupported future version
30
+ #
8
31
  def initialize(data)
9
32
  @data = data
10
33
 
@@ -20,34 +43,44 @@ module Skyfall
20
43
  raise DecodeError.new("Invalid uri: #{uri}") unless uri.start_with?('at://') || uri.start_with?('did:')
21
44
  end
22
45
 
46
+ # @return [Integer] label format version number
23
47
  def version
24
48
  @data['ver']
25
49
  end
26
50
 
51
+ # DID of the labelling authority (the labeller service).
52
+ # @return [String]
27
53
  def authority
28
54
  @data['src']
29
55
  end
30
56
 
57
+ # AT URI or DID of the labelled subject (e.g. a user or post).
58
+ # @return [String]
31
59
  def subject
32
60
  @data['uri']
33
61
  end
34
62
 
63
+ # @return [CID, nil] CID of the specific version of the subject that this label applies to
35
64
  def cid
36
65
  @cid ||= @data['cid'] && CID.from_json(@data['cid'])
37
66
  end
38
67
 
68
+ # @return [String] label value
39
69
  def value
40
70
  @data['val']
41
71
  end
42
72
 
73
+ # @return [Boolean] if true, then this is a negation (delete) of an existing label
43
74
  def negation?
44
75
  !!@data['neg']
45
76
  end
46
77
 
78
+ # @return [Time] timestamp when the label was created
47
79
  def created_at
48
80
  @created_at ||= Time.parse(@data['cts'])
49
81
  end
50
82
 
83
+ # @return [Time, nil] optional timestamp when the label expires
51
84
  def expires_at
52
85
  @expires_at ||= @data['exp'] && Time.parse(@data['exp'])
53
86
  end