skyfall 0.2.3 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3917568c1283d599d3103b428599141e04d0df2cce33c4b17291bc935b8add4d
4
- data.tar.gz: 329dd70a2f17bb689fa0a10c6646c7f522c1ac165b1e0ef49f96cd5b6d746d57
3
+ metadata.gz: 34e2166dc123a74140ce5cae67d3c000c1f88972d15cbe8a2550bde11ba4034d
4
+ data.tar.gz: 334f4b8b0ddce03b2258a0266e01e08cac9528924ac13380e91b66a0bb1217b8
5
5
  SHA512:
6
- metadata.gz: '09e6812b1234a0f7e4aeab3dee4fe962b47a781d7ddcf1151b1f310558e72837808caee9c1ca0d4e98f7e137e7147b72c8bdcd9ae17501c48f1d1dcc1a32ca35'
7
- data.tar.gz: 5c558d41a7fc49ecc34273a791dc26a706c00708ca0b00b09a59d5bdc4684ff4f14611c065e35343f65d7989a948e5294b7c9edc55b41426f5b2eae893bea392
6
+ metadata.gz: f56ab132941d80a577e5f3ba630b58d6f99548d0cde398fdb4f15e7c24d9557c3b0243d64a3940c18bf370afb219df3282c935300fc0359b6b0e00915b7002ca
7
+ data.tar.gz: 4bd7672a9450d8b1ec80ec679b69e6ba4242c4a6554865e296e7a347769795239b99a95539fe0b637407c2736b0716dfc8d1c3a9dea2d8d7a9654b47158262c9
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [0.3.0] - 2024-03-21
2
+
3
+ - added support for labeller firehose, served by labeller services at the `com.atproto.label.subscribeLabels` endpoint (aliased as `:subscribe_labels`)
4
+ - the `#labels` messages from the labeller firehose are parsed into a `LabelsMessage`, which includes a `labels` array of `Label` objects
5
+ - `Stream` callbacks can now also be assigned via setters, e.g. `stream.on_message = proc { ... }`
6
+ - added default error handler to `Stream` which logs the error to `$stdout` - set `stream.on_error = nil` to disable
7
+ - added Ruby stdlib dependencies explicitly to the gemspec - fixes a warning in Ruby 3.3 when requiring `base64`, which will be extracted as an optional gem in 3.4
8
+
9
+ ## [0.2.5] - 2024-03-14
10
+
11
+ - added `:bsky_labeler` record type symbol & collection constant
12
+
13
+ ## [0.2.4] - 2024-02-27
14
+
15
+ - added support for `#identity` message type
16
+ - added `Operation#did` as an alias of `#repo`
17
+ - added `Stream#reconnect` method which forces the websocket to reconnect
18
+ - added some validation for the `cursor` parameter in `Stream` initializer
19
+ - the `server` parameter in `Stream` initializer can be a full URL with scheme, which lets you connect to e.g. `ws://localhost` (since by default, `wss://` is used)
20
+ - tweaked `#inspect` output of `Stream` and `Operation`
21
+
1
22
  ## [0.2.3] - 2023-09-28
2
23
 
3
24
  - fixed encoding of image CIDs again (they should be wrapped in a `$link` object)
data/README.md CHANGED
@@ -22,7 +22,7 @@ Start a connection to the firehose by creating a `Skyfall::Stream` object, passi
22
22
  ```rb
23
23
  require 'skyfall'
24
24
 
25
- sky = Skyfall::Stream.new('bsky.social', :subscribe_repos)
25
+ sky = Skyfall::Stream.new('bsky.network', :subscribe_repos)
26
26
  ```
27
27
 
28
28
  Add event listeners to handle incoming messages and get notified of errors:
@@ -44,27 +44,53 @@ sky.connect
44
44
 
45
45
  ### Processing messages
46
46
 
47
- Each message passed to `on_message` is an instance of the `WebsocketMessage` class and has such properties:
47
+ Each message passed to `on_message` is an instance of a subclass of `WebsocketMessage`, depending on the message type. The supported message types are:
48
+
49
+ - `CommitMessage` (`#commit`) - represents a change in a user's repo; most messages are of this type
50
+ - `HandleMessage` (`#handle`) - when a different handle is assigned to a user's DID
51
+ - `TombstoneMessage` (`#tombstone`) - when an account is deleted
52
+ - `InfoMessage` (`#info`) - a protocol error message, e.g. about an invalid cursor parameter
53
+ - `UnknownMessage` is used for other unrecognized message types
54
+
55
+ All message objects have the following properties:
56
+
57
+ - `type` (symbol) - the message type identifier, e.g. `:commit`
58
+ - `seq` (integer) - a sequential index of the message
59
+ - `repo` or `did` (string) - DID of the repository (user account)
60
+ - `time` (Time) - timestamp of the described action
61
+
62
+ All properties except `type` may be nil for some message types that aren't related to a specific user, like `#info`.
63
+
64
+ Commit messages additionally have:
48
65
 
49
- - `type` (symbol) - usually `:commit`
50
- - `seq` (sequential number)
51
- - `time` (Time)
52
- - `repo` (string) - DID of the repository (user account)
53
66
  - `commit` - CID of the commit
54
67
  - `prev` - CID of the previous commit in that repo
55
68
  - `operations` - list of operations (usually one)
56
69
 
70
+ Handle messages additionally have:
71
+
72
+ - `handle` - the new handle assigned to the DID
73
+
74
+ Info messages additionally have:
75
+
76
+ - `name` - identifier of the message/error
77
+ - `message` - a human-readable description
78
+
79
+
80
+ ### Commit operations
81
+
57
82
  Operations are objects of type `Operation` and have such properties:
58
83
 
59
- - `repo` (string) - DID of the repository (user account)
84
+ - `repo` or `did` (string) - DID of the repository (user account)
60
85
  - `collection` (string) - name of the relevant collection in the repository, e.g. `app.bsky.feed.post` for posts
86
+ - `type` (symbol) - short name of the collection, e.g. `:bsky_post`
87
+ - `rkey` (string) - identifier of a record in a collection
61
88
  - `path` (string) - the path part of the at:// URI - collection name + ID (rkey) of the item
89
+ - `uri` (string) - the complete at:// URI
62
90
  - `action` (symbol) - `:create`, `:update` or `:delete`
63
- - `uri` (string) - the at:// URI
64
- - `type` (symbol) - short name of the collection, e.g. `:bsky_post`
65
91
  - `cid` - CID of the operation/record (`nil` for delete operations)
66
92
 
67
- Create and update operations will also have an attached record (JSON object) with details of the post, like etc. The record data is currently available as a Ruby hash via `raw_record` property (custom types will be added in a later version).
93
+ Create and update operations will also have an attached record (JSON object) with details of the post, like etc. The record data is currently available as a Ruby hash via `raw_record` property (custom types will be added in future).
68
94
 
69
95
  So for example, in order to filter only "create post" operations and print their details, you can do something like this:
70
96
 
@@ -82,7 +108,16 @@ sky.on_message do |m|
82
108
  end
83
109
  ```
84
110
 
85
- See complete example in [example/firehose.rb](https://github.com/mackuba/skyfall/blob/master/example/firehose.rb).
111
+ For more examples, see the [example](https://github.com/mackuba/skyfall/blob/master/example) folder or the [bluesky-feeds-rb](https://github.com/mackuba/bluesky-feeds-rb/blob/master/app/firehose_stream.rb) project, which implements a feed generator service.
112
+
113
+
114
+ ### Custom lexicons
115
+
116
+ A note on custom lexicons: the `Skyfall::Operation` objects have two properties that tell you the kind of record they're about: `#collection`, which is a string containing the official name of the collection/lexicon, e.g. `"app.bsky.feed.post"`; and `#type`, which is a symbol meant to save you some typing, e.g. `:bsky_post`.
117
+
118
+ When Skyfall receives a message about a record type that's not on the list, whether in the `app.bsky` namespace or not, the operation `type` will be `:unknown`, while the `collection` will be the original string. So if an app like e.g. "Skygram" appears with a `zz.skygram.*` namespace that lets you share photos on ATProto, the operations will have a type `:unknown` and collection names like `zz.skygram.feed.photo`, and you can check the `collection` field for record types known to you and process them in some appropriate way, even if Skyfall doesn't recognize the record type.
119
+
120
+ Do not however check if such operations have a `type` equal to `:unknown` first - just ignore the type and only check the `collection` string. The reason is that some next version of Skyfall might start recognizing those records and add a new `type` value for them like e.g. `:skygram_photo`, and then they won't match your condition anymore.
86
121
 
87
122
 
88
123
  ## Credits
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Example: monitor the network for people blocking your account or adding you to mute lists.
4
+
5
+ # load skyfall from a local folder - you normally won't need this
6
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
7
+
8
+ require 'json'
9
+ require 'open-uri'
10
+ require 'skyfall'
11
+
12
+ $monitored_did = ARGV[0]
13
+
14
+ if $monitored_did.to_s.empty?
15
+ puts "Usage: #{$PROGRAM_NAME} <monitored_did>"
16
+ exit 1
17
+ elsif ARGV[0] !~ /^did:plc:[a-z0-9]{24}$/
18
+ puts "Not a valid DID: #{$monitored_did}"
19
+ exit 1
20
+ end
21
+
22
+ sky = Skyfall::Stream.new('bsky.network', :subscribe_repos)
23
+
24
+ sky.on_connect { puts "Connected, monitoring #{$monitored_did}" }
25
+ sky.on_disconnect { puts "Disconnected" }
26
+ sky.on_reconnect { puts "Reconnecting..." }
27
+ sky.on_error { |e| puts "ERROR: #{e}" }
28
+
29
+ sky.on_message do |msg|
30
+ # we're only interested in repo commit messages
31
+ next if msg.type != :commit
32
+
33
+ msg.operations.each do |op|
34
+ next if op.action != :create
35
+
36
+ begin
37
+ case op.type
38
+ when :bsky_block
39
+ process_block(msg, op)
40
+ when :bsky_listitem
41
+ process_list_item(msg, op)
42
+ end
43
+ rescue StandardError => e
44
+ puts "Error: #{e}"
45
+ end
46
+ end
47
+ end
48
+
49
+ def process_block(msg, op)
50
+ if op.raw_record['subject'] == $monitored_did
51
+ owner_handle = get_user_handle(op.repo)
52
+ puts "@#{owner_handle} has blocked you! (#{msg.time.getlocal})"
53
+ end
54
+ end
55
+
56
+ def process_list_item(msg, op)
57
+ if op.raw_record['subject'] == $monitored_did
58
+ owner_handle = get_user_handle(op.repo)
59
+
60
+ list_uri = op.raw_record['list']
61
+ list_name = get_list_name(list_uri)
62
+
63
+ puts "@#{owner_handle} has added you to list \"#{list_name}\" (#{msg.time.getlocal})"
64
+ end
65
+ end
66
+
67
+ def get_user_handle(did)
68
+ url = "https://plc.directory/#{did}"
69
+ json = JSON.parse(URI.open(url).read)
70
+ json['alsoKnownAs'][0].gsub('at://', '')
71
+ end
72
+
73
+ def get_list_name(list_uri)
74
+ repo, type, rkey = list_uri.gsub('at://', '').split('/')
75
+ url = "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=#{repo}&collection=#{type}&rkey=#{rkey}"
76
+
77
+ json = JSON.parse(URI.open(url).read)
78
+ json['value']['name']
79
+ end
80
+
81
+ # close the connection cleanly on Ctrl+C
82
+ trap("SIGINT") { sky.disconnect }
83
+
84
+ sky.connect
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Example: monitor new posts for mentions of one or more words or phrases (e.g. anyone mentioning your name or the name
4
+ # of your company, project etc.).
5
+
6
+ # load skyfall from a local folder - you normally won't need this
7
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
8
+
9
+ require 'json'
10
+ require 'open-uri'
11
+ require 'skyfall'
12
+
13
+ terms = ARGV.map(&:downcase)
14
+
15
+ if terms.empty?
16
+ puts "Usage: #{$PROGRAM_NAME} <word_or_phrase> [<word_or_phrase>...]"
17
+ exit 1
18
+ end
19
+
20
+ sky = Skyfall::Stream.new('bsky.network', :subscribe_repos)
21
+
22
+ sky.on_message do |msg|
23
+ # we're only interested in repo commit messages
24
+ next if msg.type != :commit
25
+
26
+ msg.operations.each do |op|
27
+ # ignore any operations other than "create post"
28
+ next unless op.action == :create && op.type == :bsky_post
29
+
30
+ text = op.raw_record['text'].to_s.downcase
31
+
32
+ if terms.any? { |x| text.include?(x) }
33
+ owner_handle = get_user_handle(op.repo)
34
+ puts "\n#{msg.time.getlocal} @#{owner_handle}: #{op.raw_record['text']}"
35
+ end
36
+ end
37
+ end
38
+
39
+ def get_user_handle(did)
40
+ url = "https://plc.directory/#{did}"
41
+ json = JSON.parse(URI.open(url).read)
42
+ json['alsoKnownAs'][0].gsub('at://', '')
43
+ end
44
+
45
+ sky.on_connect { puts "Connected" }
46
+ sky.on_disconnect { puts "Disconnected" }
47
+ sky.on_reconnect { puts "Reconnecting..." }
48
+ sky.on_error { |e| puts "ERROR: #{e}" }
49
+
50
+ # close the connection cleanly on Ctrl+C
51
+ trap("SIGINT") { sky.disconnect }
52
+
53
+ sky.connect
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Example: print the date and text of every new post made on the network as they appear.
4
+
5
+ # load skyfall from a local folder - you normally won't need this
6
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
7
+
8
+ require 'skyfall'
9
+
10
+ sky = Skyfall::Stream.new('bsky.network', :subscribe_repos)
11
+
12
+ sky.on_message do |msg|
13
+ # we're only interested in repo commit messages
14
+ next if msg.type != :commit
15
+
16
+ msg.operations.each do |op|
17
+ # ignore any operations other than "create post"
18
+ next unless op.action == :create && op.type == :bsky_post
19
+
20
+ puts "#{op.repo} • #{msg.time.getlocal}"
21
+ puts op.raw_record['text']
22
+ puts
23
+ end
24
+ end
25
+
26
+ sky.on_connect { puts "Connected" }
27
+ sky.on_disconnect { puts "Disconnected" }
28
+ sky.on_reconnect { puts "Reconnecting..." }
29
+ sky.on_error { |e| puts "ERROR: #{e}" }
30
+
31
+ # close the connection cleanly on Ctrl+C
32
+ trap("SIGINT") { sky.disconnect }
33
+
34
+ sky.connect
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Example: send push notifications to a client app about interactions with a given account.
4
+
5
+ # load skyfall from a local folder - you normally won't need this
6
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
7
+
8
+ require 'json'
9
+ require 'open-uri'
10
+ require 'skyfall'
11
+
12
+ monitored_did = ARGV[0]
13
+
14
+ if monitored_did.to_s.empty?
15
+ puts "Usage: #{$PROGRAM_NAME} <monitored_did>"
16
+ exit 1
17
+ elsif monitored_did !~ /^did:plc:[a-z0-9]{24}$/
18
+ puts "Not a valid DID: #{monitored_did}"
19
+ exit 1
20
+ end
21
+
22
+ class InvalidURIException < StandardError
23
+ def initialize(uri)
24
+ super("Invalid AT URI: #{uri}")
25
+ end
26
+ end
27
+
28
+ class AtURI
29
+ attr_reader :did, :collection, :rkey
30
+
31
+ def initialize(uri)
32
+ if uri =~ /\Aat:\/\/(did:[\w]+:[\w\.\-]+)\/([\w\.]+)\/([\w\-]+)\z/
33
+ @did = $1
34
+ @collection = $2
35
+ @rkey = $3
36
+ else
37
+ raise InvalidURIException, uri
38
+ end
39
+ end
40
+ end
41
+
42
+ class NotificationEngine
43
+ def initialize(user_did)
44
+ @user_did = user_did
45
+ end
46
+
47
+ def connect
48
+ @sky = Skyfall::Stream.new('bsky.network', :subscribe_repos)
49
+
50
+ @sky.on_connect { puts "Connected, monitoring #{@user_did}" }
51
+ @sky.on_disconnect { puts "Disconnected" }
52
+ @sky.on_reconnect { puts "Reconnecting..." }
53
+ @sky.on_error { |e| puts "ERROR: #{e}" }
54
+
55
+ @sky.on_message do |msg|
56
+ process_message(msg)
57
+ end
58
+
59
+ @sky.connect
60
+ end
61
+
62
+ def disconnect
63
+ @sky.disconnect
64
+ end
65
+
66
+ def process_message(msg)
67
+ # we're only interested in repo commit messages
68
+ return if msg.type != :commit
69
+
70
+ # ignore user's own actions
71
+ return if msg.repo == @user_did
72
+
73
+ msg.operations.each do |op|
74
+ next if op.action != :create
75
+
76
+ begin
77
+ case op.type
78
+ when :bsky_post
79
+ process_post(msg, op)
80
+ when :bsky_like
81
+ process_like(msg, op)
82
+ when :bsky_repost
83
+ process_repost(msg, op)
84
+ when :bsky_follow
85
+ process_follow(msg, op)
86
+ end
87
+ rescue StandardError => e
88
+ puts "Error: #{e}"
89
+ end
90
+ end
91
+ end
92
+
93
+
94
+ # posts
95
+
96
+ def process_post(msg, op)
97
+ data = op.raw_record
98
+
99
+ if reply = data['reply']
100
+ # check for replies (direct only)
101
+ if reply['parent'] && reply['parent']['uri']
102
+ parent_uri = AtURI.new(reply['parent']['uri'])
103
+
104
+ if parent_uri.did == @user_did
105
+ send_reply_notification(msg, op)
106
+ end
107
+ end
108
+ end
109
+
110
+ if embed = data['embed']
111
+ # check for quotes
112
+ if embed['record'] && embed['record']['uri']
113
+ quoted_uri = AtURI.new(embed['record']['uri'])
114
+
115
+ if quoted_uri.did == @user_did
116
+ send_quote_notification(msg, op)
117
+ end
118
+ end
119
+
120
+ # second type of quote (recordWithMedia)
121
+ if embed['record'] && embed['record']['record'] && embed['record']['record']['uri']
122
+ quoted_uri = AtURI.new(embed['record']['record']['uri'])
123
+
124
+ if quoted_uri.did == @user_did
125
+ send_quote_notification(msg, op)
126
+ end
127
+ end
128
+ end
129
+
130
+ if facets = data['facets']
131
+ # check for mentions
132
+ if facets.any? { |f| f['features'] && f['features'].any? { |x| x['did'] == @user_did }}
133
+ send_mention_notification(msg, op)
134
+ end
135
+ end
136
+ end
137
+
138
+ def send_reply_notification(msg, op)
139
+ handle = get_user_handle(msg.repo)
140
+
141
+ send_push("@#{handle} replied:", op.raw_record)
142
+ end
143
+
144
+ def send_quote_notification(msg, op)
145
+ handle = get_user_handle(msg.repo)
146
+
147
+ send_push("@#{handle} quoted you:", op.raw_record)
148
+ end
149
+
150
+ def send_mention_notification(msg, op)
151
+ handle = get_user_handle(msg.repo)
152
+
153
+ send_push("@#{handle} mentioned you:", op.raw_record)
154
+ end
155
+
156
+
157
+ # likes
158
+
159
+ def process_like(msg, op)
160
+ data = op.raw_record
161
+
162
+ if data['subject'] && data['subject']['uri']
163
+ liked_uri = AtURI.new(data['subject']['uri'])
164
+
165
+ if liked_uri.did == @user_did
166
+ case liked_uri.collection
167
+ when 'app.bsky.feed.post'
168
+ send_post_like_notification(msg, liked_uri)
169
+ when 'app.bsky.feed.generator'
170
+ send_feed_like_notification(msg, liked_uri)
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ def send_post_like_notification(msg, uri)
177
+ handle = get_user_handle(msg.repo)
178
+ post = get_record(uri)
179
+
180
+ send_push("@#{handle} liked your post", post)
181
+ end
182
+
183
+ def send_feed_like_notification(msg, uri)
184
+ handle = get_user_handle(msg.repo)
185
+ feed = get_record(uri)
186
+
187
+ send_push("@#{handle} liked your feed", feed)
188
+ end
189
+
190
+
191
+ # reposts
192
+
193
+ def process_repost(msg, op)
194
+ data = op.raw_record
195
+
196
+ if data['subject'] && data['subject']['uri']
197
+ reposted_uri = AtURI.new(data['subject']['uri'])
198
+
199
+ if reposted_uri.did == @user_did && reposted_uri.collection == 'app.bsky.feed.post'
200
+ send_repost_notification(msg, reposted_uri)
201
+ end
202
+ end
203
+ end
204
+
205
+ def send_repost_notification(msg, uri)
206
+ handle = get_user_handle(msg.repo)
207
+ post = get_record(uri)
208
+
209
+ send_push("@#{handle} reposted your post", post)
210
+ end
211
+
212
+
213
+ # follows
214
+
215
+ def process_follow(msg, op)
216
+ if op.raw_record['subject'] == @user_did
217
+ send_follow_notification(msg)
218
+ end
219
+ end
220
+
221
+ def send_follow_notification(msg)
222
+ handle = get_user_handle(msg.repo)
223
+
224
+ send_push("@#{handle} followed you", msg.repo)
225
+ end
226
+
227
+
228
+ #
229
+ # Note: in this example, we're calling the Bluesky AppView to get details about the person interacting with the user
230
+ # and the post/feed that was liked/reposted etc. In a real app, you might run into rate limits if you do that,
231
+ # because these requests will all be sent from the server's IP.
232
+ #
233
+ # So you might need to take a different route and send just the info that you have here in the push notification data
234
+ # (the AT URI / DID) and fetch the details on the client side, e.g. in a Notification Service Extension on iOS.
235
+ #
236
+
237
+ def get_user_handle(did)
238
+ url = "https://api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=#{did}"
239
+ json = JSON.parse(URI.open(url).read)
240
+ json['handle']
241
+ end
242
+
243
+ def get_record(uri)
244
+ url = "https://api.bsky.app/xrpc/com.atproto.repo.getRecord?" +
245
+ "repo=#{uri.did}&collection=#{uri.collection}&rkey=#{uri.rkey}"
246
+ json = JSON.parse(URI.open(url).read)
247
+ json['value']
248
+ end
249
+
250
+ def send_push(message, data = nil)
251
+ # send the message to APNS/FCM here
252
+ puts
253
+ puts "[#{Time.now}] #{message} #{data&.inspect}"
254
+ end
255
+ end
256
+
257
+ engine = NotificationEngine.new(monitored_did)
258
+
259
+ # close the connection cleanly on Ctrl+C
260
+ trap("SIGINT") { engine.disconnect }
261
+
262
+ engine.connect
@@ -11,5 +11,6 @@ module Skyfall
11
11
  BSKY_LIST = "app.bsky.graph.list"
12
12
  BSKY_LISTBLOCK = "app.bsky.graph.listblock"
13
13
  BSKY_LISTITEM = "app.bsky.graph.listitem"
14
+ BSKY_LABELER = "app.bsky.labeler.service"
14
15
  end
15
16
  end
@@ -0,0 +1,63 @@
1
+ require_relative 'errors'
2
+ require 'time'
3
+
4
+ module Skyfall
5
+ class Label
6
+ attr_reader :data
7
+
8
+ def initialize(data)
9
+ @data = data
10
+
11
+ raise DecodeError.new("Missing version: #{data}") unless data.has_key?('ver')
12
+ raise DecodeError.new("Invalid version: #{ver}") unless ver.is_a?(Integer) && ver >= 1
13
+ raise UnsupportedError.new("Unsupported version: #{ver}") unless ver == 1
14
+
15
+ raise DecodeError.new("Missing source: #{data}") unless data.has_key?('src')
16
+ raise DecodeError.new("Invalid source: #{src}") unless src.is_a?(String) && src.start_with?('did:')
17
+
18
+ raise DecodeError.new("Missing uri: #{data}") unless data.has_key?('uri')
19
+ raise DecodeError.new("Invalid uri: #{uri}") unless uri.is_a?(String)
20
+ raise DecodeError.new("Invalid uri: #{uri}") unless uri.start_with?('at://') || uri.start_with?('did:')
21
+ end
22
+
23
+ def version
24
+ @data['ver']
25
+ end
26
+
27
+ def authority
28
+ @data['src']
29
+ end
30
+
31
+ def subject
32
+ @data['uri']
33
+ end
34
+
35
+ def cid
36
+ @cid ||= @data['cid'] && CID.from_json(@data['cid'])
37
+ end
38
+
39
+ def value
40
+ @data['val']
41
+ end
42
+
43
+ def negation?
44
+ !!@data['neg']
45
+ end
46
+
47
+ def created_at
48
+ @created_at ||= Time.parse(@data['cts'])
49
+ end
50
+
51
+ def expires_at
52
+ @expires_at ||= @data['exp'] && Time.parse(@data['exp'])
53
+ end
54
+
55
+ alias ver version
56
+ alias src authority
57
+ alias uri subject
58
+ alias val value
59
+ alias neg negation?
60
+ alias cts created_at
61
+ alias exp expires_at
62
+ end
63
+ end
@@ -0,0 +1,4 @@
1
+ module Skyfall
2
+ class IdentityMessage < WebsocketMessage
3
+ end
4
+ end
@@ -0,0 +1,23 @@
1
+ require_relative 'websocket_message'
2
+ require_relative '../label'
3
+
4
+ module Skyfall
5
+ class LabelsMessage
6
+ using Skyfall::Extensions
7
+
8
+ attr_reader :type_object, :data_object
9
+ attr_reader :type, :seq
10
+
11
+ def initialize(type_object, data_object)
12
+ @type_object = type_object
13
+ @data_object = data_object
14
+
15
+ @type = @type_object['t'][1..-1].to_sym
16
+ @seq = @data_object['seq']
17
+ end
18
+
19
+ def labels
20
+ @labels ||= @data_object['labels'].map { |x| Label.new(x) }
21
+ end
22
+ end
23
+ end
@@ -10,7 +10,9 @@ module Skyfall
10
10
 
11
11
  require_relative 'commit_message'
12
12
  require_relative 'handle_message'
13
+ require_relative 'identity_message'
13
14
  require_relative 'info_message'
15
+ require_relative 'labels_message'
14
16
  require_relative 'tombstone_message'
15
17
  require_relative 'unknown_message'
16
18
 
@@ -25,7 +27,9 @@ module Skyfall
25
27
  message_class = case type_object['t']
26
28
  when '#commit' then CommitMessage
27
29
  when '#handle' then HandleMessage
30
+ when '#identity' then IdentityMessage
28
31
  when '#info' then InfoMessage
32
+ when '#labels' then LabelsMessage
29
33
  when '#tombstone' then TombstoneMessage
30
34
  else UnknownMessage
31
35
  end
@@ -11,6 +11,8 @@ module Skyfall
11
11
  @message.repo
12
12
  end
13
13
 
14
+ alias did repo
15
+
14
16
  def path
15
17
  @json['path']
16
18
  end
@@ -44,6 +46,7 @@ module Skyfall
44
46
  when Collection::BSKY_BLOCK then :bsky_block
45
47
  when Collection::BSKY_FEED then :bsky_feed
46
48
  when Collection::BSKY_FOLLOW then :bsky_follow
49
+ when Collection::BSKY_LABELER then :bsky_labeler
47
50
  when Collection::BSKY_LIKE then :bsky_like
48
51
  when Collection::BSKY_LIST then :bsky_list
49
52
  when Collection::BSKY_LISTBLOCK then :bsky_listblock
@@ -55,5 +58,14 @@ module Skyfall
55
58
  else :unknown
56
59
  end
57
60
  end
61
+
62
+ def inspectable_variables
63
+ instance_variables - [:@message]
64
+ end
65
+
66
+ def inspect
67
+ vars = inspectable_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(", ")
68
+ "#<#{self.class}:0x#{object_id} #{vars}>"
69
+ end
58
70
  end
59
71
  end
@@ -7,26 +7,28 @@ require 'uri'
7
7
  module Skyfall
8
8
  class Stream
9
9
  SUBSCRIBE_REPOS = "com.atproto.sync.subscribeRepos"
10
+ SUBSCRIBE_LABELS = "com.atproto.label.subscribeLabels"
10
11
 
11
12
  NAMED_ENDPOINTS = {
12
- :subscribe_repos => SUBSCRIBE_REPOS
13
+ :subscribe_repos => SUBSCRIBE_REPOS,
14
+ :subscribe_labels => SUBSCRIBE_LABELS
13
15
  }
14
16
 
17
+ EVENTS = %w(message raw_message connecting connect disconnect reconnect error)
18
+
15
19
  MAX_RECONNECT_INTERVAL = 300
16
20
 
17
21
  attr_accessor :heartbeat_timeout, :heartbeat_interval, :cursor, :auto_reconnect
18
22
 
19
23
  def initialize(server, endpoint, cursor = nil)
20
24
  @endpoint = check_endpoint(endpoint)
21
- @server = check_hostname(server)
22
- @cursor = cursor
25
+ @root_url = build_root_url(server)
26
+ @cursor = check_cursor(cursor)
23
27
  @handlers = {}
24
- @heartbeat_mutex = Mutex.new
25
- @heartbeat_interval = 5
26
- @heartbeat_timeout = 30
27
- @last_update = nil
28
28
  @auto_reconnect = true
29
29
  @connection_attempts = 0
30
+
31
+ @handlers[:error] = proc { |e| puts "ERROR: #{e}" }
30
32
  end
31
33
 
32
34
  def connect
@@ -37,6 +39,9 @@ module Skyfall
37
39
  @handlers[:connecting]&.call(url)
38
40
  @engines_on = true
39
41
 
42
+ @reconnect_timer&.cancel
43
+ @reconnect_timer = nil
44
+
40
45
  EM.run do
41
46
  EventMachine.error_handler do |e|
42
47
  @handlers[:error]&.call(e)
@@ -49,6 +54,7 @@ module Skyfall
49
54
  end
50
55
 
51
56
  @ws.on(:message) do |msg|
57
+ @reconnecting = false
52
58
  @connection_attempts = 0
53
59
 
54
60
  data = msg.data.pack('C*')
@@ -70,10 +76,12 @@ module Skyfall
70
76
  @ws.on(:close) do |e|
71
77
  @ws = nil
72
78
 
73
- if @auto_reconnect && @engines_on
74
- EM.add_timer(reconnect_delay) do
79
+ if @reconnecting || @auto_reconnect && @engines_on
80
+ @handlers[:reconnect]&.call
81
+
82
+ @reconnect_timer&.cancel
83
+ @reconnect_timer = EM::Timer.new(reconnect_delay) do
75
84
  @connection_attempts += 1
76
- @handlers[:reconnect]&.call
77
85
  connect
78
86
  end
79
87
  else
@@ -85,41 +93,40 @@ module Skyfall
85
93
  end
86
94
  end
87
95
 
96
+ def reconnect
97
+ @reconnecting = true
98
+ @connection_attempts = 0
99
+
100
+ @ws ? @ws.close : connect
101
+ end
102
+
88
103
  def disconnect
89
104
  return unless EM.reactor_running?
90
105
 
106
+ @reconnecting = false
91
107
  @engines_on = false
92
108
  EM.stop_event_loop
93
109
  end
94
110
 
95
111
  alias close disconnect
96
112
 
97
- def on_message(&block)
98
- @handlers[:message] = block
99
- end
100
-
101
- def on_raw_message(&block)
102
- @handlers[:raw_message] = block
103
- end
104
-
105
- def on_connecting(&block)
106
- @handlers[:connecting] = block
107
- end
108
-
109
- def on_connect(&block)
110
- @handlers[:connect] = block
111
- end
113
+ EVENTS.each do |event|
114
+ define_method "on_#{event}" do |&block|
115
+ @handlers[event.to_sym] = block
116
+ end
112
117
 
113
- def on_disconnect(&block)
114
- @handlers[:disconnect] = block
118
+ define_method "on_#{event}=" do |block|
119
+ @handlers[event.to_sym] = block
120
+ end
115
121
  end
116
122
 
117
- def on_error(&block)
118
- @handlers[:error] = block
123
+ def inspectable_variables
124
+ instance_variables - [:@handlers, :@ws]
119
125
  end
120
126
 
121
- def on_reconnect(&block)
122
- @handlers[:reconnect] = block
127
+ def inspect
128
+ vars = inspectable_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(", ")
129
+ "#<#{self.class}:0x#{object_id} #{vars}>"
123
130
  end
124
131
 
125
132
 
@@ -134,32 +141,44 @@ module Skyfall
134
141
  end
135
142
 
136
143
  def build_websocket_url
137
- url = "wss://#{@server}/xrpc/#{@endpoint}"
138
- url += "?cursor=#{@cursor}" if @cursor
139
- url
144
+ @root_url + "/xrpc/" + @endpoint + (@cursor ? "?cursor=#{@cursor}" : "")
145
+ end
146
+
147
+ def check_cursor(cursor)
148
+ if cursor.nil?
149
+ nil
150
+ elsif cursor.is_a?(Integer) || cursor.is_a?(String) && cursor =~ /^[0-9]+$/
151
+ cursor.to_i
152
+ else
153
+ raise ArgumentError, "Invalid cursor: #{cursor.inspect} - cursor must be an integer number"
154
+ end
140
155
  end
141
156
 
142
157
  def check_endpoint(endpoint)
143
158
  if endpoint.is_a?(String)
144
- raise ArgumentError("Invalid endpoint name: #{endpoint}") if endpoint.strip.empty? || !endpoint.include?('.')
159
+ raise ArgumentError.new("Invalid endpoint name: #{endpoint}") if endpoint.strip == '' || !endpoint.include?('.')
145
160
  elsif endpoint.is_a?(Symbol)
146
- raise ArgumentError("Unknown endpoint: #{endpoint}") if NAMED_ENDPOINTS[endpoint].nil?
161
+ raise ArgumentError.new("Unknown endpoint: #{endpoint}") if NAMED_ENDPOINTS[endpoint].nil?
147
162
  endpoint = NAMED_ENDPOINTS[endpoint]
148
163
  else
149
- raise ArgumentError("Endpoint should be a string or a symbol")
164
+ raise ArgumentError, "Endpoint should be a string or a symbol"
150
165
  end
151
166
 
152
167
  endpoint
153
168
  end
154
169
 
155
- def check_hostname(server)
170
+ def build_root_url(server)
156
171
  if server.is_a?(String)
157
- raise ArgumentError("Invalid server name: #{server}") if server.strip.empty? || server.include?('/')
172
+ if server.start_with?('ws://') || server.start_with?('wss://')
173
+ server
174
+ elsif server.strip.empty? || server.include?('/')
175
+ raise ArgumentError, "Server parameter should be a hostname or a ws:// or wss:// URL"
176
+ else
177
+ "wss://#{server}"
178
+ end
158
179
  else
159
- raise ArgumentError("Server name should be a string")
180
+ raise ArgumentError, "Server parameter should be a string"
160
181
  end
161
-
162
- server
163
182
  end
164
183
  end
165
184
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Skyfall
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skyfall
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-09-28 00:00:00.000000000 Z
11
+ date: 2024-03-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base32
@@ -50,20 +50,96 @@ dependencies:
50
50
  - - ">="
51
51
  - !ruby/object:Gem::Version
52
52
  version: 0.5.9.6
53
+ - !ruby/object:Gem::Dependency
54
+ name: eventmachine
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '1.2'
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 1.2.7
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '1.2'
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 1.2.7
53
73
  - !ruby/object:Gem::Dependency
54
74
  name: faye-websocket
55
75
  requirement: !ruby/object:Gem::Requirement
56
76
  requirements:
57
77
  - - "~>"
58
78
  - !ruby/object:Gem::Version
59
- version: 0.11.2
79
+ version: '0.11'
80
+ type: :runtime
81
+ prerelease: false
82
+ version_requirements: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '0.11'
87
+ - !ruby/object:Gem::Dependency
88
+ name: base64
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '0.1'
94
+ type: :runtime
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '0.1'
101
+ - !ruby/object:Gem::Dependency
102
+ name: stringio
103
+ requirement: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '3.0'
108
+ type: :runtime
109
+ prerelease: false
110
+ version_requirements: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '3.0'
115
+ - !ruby/object:Gem::Dependency
116
+ name: time
117
+ requirement: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '0.3'
122
+ type: :runtime
123
+ prerelease: false
124
+ version_requirements: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - "~>"
127
+ - !ruby/object:Gem::Version
128
+ version: '0.3'
129
+ - !ruby/object:Gem::Dependency
130
+ name: uri
131
+ requirement: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - "~>"
134
+ - !ruby/object:Gem::Version
135
+ version: '0.13'
60
136
  type: :runtime
61
137
  prerelease: false
62
138
  version_requirements: !ruby/object:Gem::Requirement
63
139
  requirements:
64
140
  - - "~>"
65
141
  - !ruby/object:Gem::Version
66
- version: 0.11.2
142
+ version: '0.13'
67
143
  description: "\n Skyfall is a Ruby library for connecting to the \"firehose\" of
68
144
  the Bluesky social network, i.e. a websocket which\n streams all new posts and
69
145
  everything else happening on the Bluesky network in real time. The code connects
@@ -79,16 +155,22 @@ files:
79
155
  - CHANGELOG.md
80
156
  - LICENSE.txt
81
157
  - README.md
82
- - example/firehose.rb
158
+ - example/block_tracker.rb
159
+ - example/monitor_phrases.rb
160
+ - example/print_all_posts.rb
161
+ - example/push_notifications.rb
83
162
  - lib/skyfall.rb
84
163
  - lib/skyfall/car_archive.rb
85
164
  - lib/skyfall/cid.rb
86
165
  - lib/skyfall/collection.rb
87
166
  - lib/skyfall/errors.rb
88
167
  - lib/skyfall/extensions.rb
168
+ - lib/skyfall/label.rb
89
169
  - lib/skyfall/messages/commit_message.rb
90
170
  - lib/skyfall/messages/handle_message.rb
171
+ - lib/skyfall/messages/identity_message.rb
91
172
  - lib/skyfall/messages/info_message.rb
173
+ - lib/skyfall/messages/labels_message.rb
92
174
  - lib/skyfall/messages/tombstone_message.rb
93
175
  - lib/skyfall/messages/unknown_message.rb
94
176
  - lib/skyfall/messages/websocket_message.rb
data/example/firehose.rb DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- lib = File.expand_path('../../lib', __FILE__)
4
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
-
6
- require 'skyfall'
7
-
8
- sky = Skyfall::Stream.new('bsky.social', :subscribe_repos)
9
-
10
- sky.on_message do |m|
11
- next if m.type != :commit
12
-
13
- m.operations.each do |op|
14
- next unless op.action == :create && op.type == :bsky_post
15
-
16
- puts "#{op.repo}:"
17
- puts op.raw_record['text']
18
- puts
19
- end
20
- end
21
-
22
- sky.on_connect { puts "Connected" }
23
- sky.on_disconnect { puts "Disconnected" }
24
- sky.on_reconnect { puts "Reconnecting..." }
25
- sky.on_error { |e| puts "ERROR: #{e}" }
26
-
27
- sky.connect