skyfall 0.5.1 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fb36995d08abb6e93e1ce9f29ca74a88cea54bf21b3a1ae0d66a1cb9feb9240
4
- data.tar.gz: b9c21937b269d0cae220563bd912ce13d0d381bec920a139d40c33b6ace06253
3
+ metadata.gz: 5d216f6eca172d8982c358d594511fa31212dd9dfdb949ed1149d16b23862bfa
4
+ data.tar.gz: c381c2abebdb22265568b0183b7d78525a6cbbbd01192b32faad4ed1dadbec02
5
5
  SHA512:
6
- metadata.gz: f71bc939578c8fd50278562c28c19e00e558297e93440f1627b567df8f0c5fa3dc88b22efa2cd5a284fc58c241d1f66a21f9c482b7dda3fc799263ebfd777ee0
7
- data.tar.gz: 579db31d36c8fcee844f3d26193eea2fcdc9721e037ac5c6d1e1ee20362059f9051afd1fb56fab4ba15e566add9571751e85d8b0663fda190f80756959882468
6
+ metadata.gz: 1d4c45ef3103036b5e13c5614615f20b61d9beaa7092af2058b8135cd4b460ede00c23b1d05cc5b57751a64a50e6286946d438ccd80c899023c9d92be6ac710e
7
+ data.tar.gz: d7bd5bb732b86db3c56b9389a25a5e6d80f57629ec27621c32330ad0155c379864388eb347381e8f5e38387c4b22ce019fd4879c0e33eb46852e1565c2aef844
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.6.1] - 2026-01-08
2
+
3
+ - added `:bsky_notif_declaration` shortcode for `app.bsky.notification.declaration` collection
4
+ - throw error when trying to run two streams in one process (see b4a1514f5da28983205765e55724b5c4abe6c5e4 for details)
5
+ - added protected `#send_data` and `#socket` methods in `Stream` for use in `Stream` subclasses (currently for the Tapfall gem)
6
+ - added a way to customize headers sent when connecting in `Stream` subclasses through the `#request_headers` method
7
+
8
+ ## [0.6.0] - 2025-06-25
9
+
10
+ - significantly speeded up reading of events from the binary firehose (`Skyfall::Firehose`) - up to 4-5x faster than before
11
+ - removed the `Skyfall::Stream.new` constructor deprecated in 0.5.0
12
+
1
13
  ## [0.5.1] - 2025-05-18
2
14
 
3
15
  - added support for the new `#sync` message type
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The zlib License
2
2
 
3
- Copyright (c) 2023-2024 Jakub Suder
3
+ Copyright (c) 2026 Jakub Suder
4
4
 
5
5
  This software is provided 'as-is', without any express or implied
6
6
  warranty. In no event will the authors be held liable for any damages
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A Ruby gem for streaming data from the Bluesky/ATProto firehose 🦋
4
4
 
5
5
  > [!NOTE]
6
- > ATProto Ruby gems collection: [skyfall](https://github.com/mackuba/skyfall) | [blue_factory](https://github.com/mackuba/blue_factory) | [minisky](https://github.com/mackuba/minisky) | [didkit](https://github.com/mackuba/didkit)
6
+ > Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
7
7
 
8
8
 
9
9
  ## What does it do
@@ -15,13 +15,15 @@ Since version 0.5, Skyfall also supports connecting to [Jetstream](https://githu
15
15
 
16
16
  ## Installation
17
17
 
18
- From the command line:
18
+ To use Skyfall, you need a reasonably new version of Ruby – it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
19
19
 
20
- gem install skyfall
20
+ To install the gem, run the command:
21
21
 
22
- Or, add this to your `Gemfile`:
22
+ [sudo] gem install skyfall
23
23
 
24
- gem 'skyfall', '~> 0.5'
24
+ Or add this to your app's `Gemfile`:
25
+
26
+ gem 'skyfall', '~> 0.6'
25
27
 
26
28
 
27
29
  ## Usage
@@ -198,7 +200,7 @@ sky.on_message do |m|
198
200
  end
199
201
  ```
200
202
 
201
- 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.
203
+ For more examples, see the [examples page](https://ruby.sdk.blue/examples/) on [ruby.sdk.blue](https://ruby.sdk.blue), or the [bluesky-feeds-rb](https://tangled.org/mackuba.eu/bluesky-feeds-rb/blob/master/app/firehose_stream.rb) project, which implements a feed generator service.
202
204
 
203
205
 
204
206
  ### Note on custom lexicons
@@ -305,7 +307,7 @@ See [Jetstream docs](https://github.com/bluesky-social/jetstream?tab=readme-ov-f
305
307
 
306
308
  ## Credits
307
309
 
308
- Copyright © 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
310
+ Copyright © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
309
311
 
310
312
  The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
311
313
 
@@ -17,7 +17,8 @@ module Skyfall
17
17
  BSKY_VERIFICATION = "app.bsky.graph.verification"
18
18
  BSKY_LABELER = "app.bsky.labeler.service"
19
19
 
20
- BSKY_CHAT_DECLARATION = "chat.bsky.actor.declaration"
20
+ BSKY_NOTIF_DECLARATION = "app.bsky.notification.declaration"
21
+ BSKY_CHAT_DECLARATION = "chat.bsky.actor.declaration"
21
22
 
22
23
  SHORT_CODES = {
23
24
  BSKY_ACTOR_STATUS => :bsky_actor_status,
@@ -37,6 +38,7 @@ module Skyfall
37
38
  BSKY_THREADGATE => :bsky_threadgate,
38
39
  BSKY_VERIFICATION => :bsky_verification,
39
40
  BSKY_CHAT_DECLARATION => :bsky_chat_declaration,
41
+ BSKY_NOTIF_DECLARATION => :bsky_notif_declaration
40
42
  }
41
43
 
42
44
  def self.short_code(collection)
@@ -5,6 +5,15 @@ module Skyfall
5
5
  class UnsupportedError < StandardError
6
6
  end
7
7
 
8
+ class ReactorActiveError < StandardError
9
+ def initialize
10
+ super(
11
+ "An EventMachine reactor thread is already running, but it seems to have been launched by another Stream. " +
12
+ "Skyfall doesn't currently support running two different Stream instances in a single process."
13
+ )
14
+ end
15
+ end
16
+
8
17
  class SubscriptionError < StandardError
9
18
  attr_reader :error_type, :error_message
10
19
 
@@ -13,28 +13,17 @@ module Skyfall
13
13
 
14
14
  attr_accessor :cursor
15
15
 
16
- def self.new(server, endpoint, cursor = nil)
17
- # to be removed in 0.6
18
- instance = self.allocate
19
- instance.send(:initialize, server, endpoint, cursor)
20
- instance
21
- end
22
-
23
16
  def initialize(server, endpoint, cursor = nil)
24
17
  require_relative 'firehose/message'
25
18
  super(server)
26
19
 
27
20
  @endpoint = check_endpoint(endpoint)
28
21
  @cursor = check_cursor(cursor)
29
- @root_url = @root_url.chomp('/')
30
-
31
- if URI(@root_url).path != ''
32
- raise ArgumentError, "Server parameter should not include any path"
33
- end
22
+ @root_url = ensure_empty_path(@root_url)
34
23
  end
35
24
 
36
25
  def handle_message(msg)
37
- data = msg.data.pack('C*')
26
+ data = msg.data
38
27
  @handlers[:raw_message]&.call(data)
39
28
 
40
29
  if @handlers[:message]
@@ -6,27 +6,15 @@ require 'uri'
6
6
 
7
7
  module Skyfall
8
8
  class Jetstream < Stream
9
- def self.new(server, params = {})
10
- # to be removed in 0.6
11
- instance = self.allocate
12
- instance.send(:initialize, server, params)
13
- instance
14
- end
15
-
16
9
  attr_accessor :cursor
17
10
 
18
11
  def initialize(server, params = {})
19
12
  require_relative 'jetstream/message'
20
13
  super(server)
21
14
 
22
- @root_url = @root_url.chomp('/')
23
-
24
- if URI(@root_url).path != ''
25
- raise ArgumentError, "Server parameter should not include any path"
26
- end
27
-
28
15
  @params = check_params(params)
29
16
  @cursor = @params.delete(:cursor)
17
+ @root_url = ensure_empty_path(@root_url)
30
18
  end
31
19
 
32
20
  def handle_message(msg)
@@ -12,20 +12,8 @@ module Skyfall
12
12
  attr_accessor :auto_reconnect, :last_update, :user_agent
13
13
  attr_accessor :heartbeat_timeout, :heartbeat_interval, :check_heartbeat
14
14
 
15
- def self.new(server, endpoint = nil, cursor = nil)
16
- # to be removed in 0.6
17
- if endpoint || cursor
18
- STDERR.puts "Warning: Skyfall::Stream has been renamed to Skyfall::Firehose. This initializer will be removed in the next version."
19
- Firehose.new(server, endpoint, cursor)
20
- else
21
- instance = self.allocate
22
- instance.send(:initialize, server)
23
- instance
24
- end
25
- end
26
-
27
- def initialize(service)
28
- @root_url = build_root_url(service)
15
+ def initialize(server)
16
+ @root_url = build_root_url(server)
29
17
 
30
18
  @handlers = {}
31
19
  @auto_reconnect = true
@@ -45,11 +33,14 @@ module Skyfall
45
33
  url = build_websocket_url
46
34
 
47
35
  @handlers[:connecting]&.call(url)
48
- @engines_on = true
49
36
 
50
37
  @reconnect_timer&.cancel
51
38
  @reconnect_timer = nil
52
39
 
40
+ raise ReactorActiveError if existing_reactor?
41
+
42
+ @engines_on = true
43
+
53
44
  EM.run do
54
45
  EventMachine.error_handler do |e|
55
46
  @handlers[:error]&.call(e)
@@ -175,8 +166,27 @@ module Skyfall
175
166
  end
176
167
 
177
168
 
169
+ protected
170
+
171
+ def request_headers
172
+ {}
173
+ end
174
+
175
+ def socket
176
+ @ws
177
+ end
178
+
179
+ def send_data(data)
180
+ @ws.send(data)
181
+ end
182
+
183
+
178
184
  private
179
185
 
186
+ def existing_reactor?
187
+ EM.reactor_running? && !@engines_on
188
+ end
189
+
180
190
  def reconnect_delay
181
191
  if @connection_attempts == 0
182
192
  0
@@ -186,29 +196,41 @@ module Skyfall
186
196
  end
187
197
 
188
198
  def build_websocket_client(url)
189
- Faye::WebSocket::Client.new(url, nil, { headers: { 'User-Agent' => user_agent }})
199
+ Faye::WebSocket::Client.new(url, nil, { headers: { 'User-Agent' => user_agent }.merge(request_headers) })
190
200
  end
191
201
 
192
202
  def build_websocket_url
193
203
  @root_url
194
204
  end
195
205
 
196
- def build_root_url(service)
197
- if service.is_a?(String)
198
- if service.include?('/')
199
- uri = URI(service)
200
- if uri.scheme != 'ws' && uri.scheme != 'wss'
201
- raise ArgumentError, "Service parameter should be a hostname or a ws:// or wss:// URL"
202
- end
203
- uri.to_s
204
- else
205
- service = "wss://#{service}"
206
- uri = URI(service) # raises if invalid
207
- service
206
+ def build_root_url(server)
207
+ if !server.is_a?(String)
208
+ raise ArgumentError, "Server parameter should be a string"
209
+ end
210
+
211
+ if server.include?('://')
212
+ uri = URI(server)
213
+
214
+ if uri.scheme != 'ws' && uri.scheme != 'wss'
215
+ raise ArgumentError, "Server parameter should be a hostname or a ws:// or wss:// URL"
208
216
  end
217
+
218
+ uri.to_s
209
219
  else
210
- raise ArgumentError, "Service parameter should be a string"
220
+ server = "wss://#{server}"
221
+ uri = URI(server) # raises if invalid
222
+ server
211
223
  end
212
224
  end
225
+
226
+ def ensure_empty_path(url)
227
+ url = url.chomp('/')
228
+
229
+ if URI(url).path != ''
230
+ raise ArgumentError, "Server URL should only include a hostname, without any path"
231
+ end
232
+
233
+ url
234
+ end
213
235
  end
214
236
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Skyfall
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skyfall
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-05-19 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base32
@@ -89,14 +89,14 @@ dependencies:
89
89
  requirements:
90
90
  - - "~>"
91
91
  - !ruby/object:Gem::Version
92
- version: '0.11'
92
+ version: '0.12'
93
93
  type: :runtime
94
94
  prerelease: false
95
95
  version_requirements: !ruby/object:Gem::Requirement
96
96
  requirements:
97
97
  - - "~>"
98
98
  - !ruby/object:Gem::Version
99
- version: '0.11'
99
+ version: '0.12'
100
100
  description: "\n Skyfall is a Ruby library for connecting to the \"firehose\" of
101
101
  the Bluesky social network, i.e. a websocket which\n streams all new posts and
102
102
  everything else happening on the Bluesky network in real time. The code connects
@@ -112,17 +112,12 @@ files:
112
112
  - CHANGELOG.md
113
113
  - LICENSE.txt
114
114
  - README.md
115
- - example/block_tracker.rb
116
- - example/jet_monitor_phrases.rb
117
- - example/print_all_posts.rb
118
- - example/push_notifications.rb
119
115
  - lib/skyfall.rb
120
116
  - lib/skyfall/car_archive.rb
121
117
  - lib/skyfall/cid.rb
122
118
  - lib/skyfall/collection.rb
123
119
  - lib/skyfall/errors.rb
124
120
  - lib/skyfall/extensions.rb
125
- - lib/skyfall/faye_ext.rb
126
121
  - lib/skyfall/firehose.rb
127
122
  - lib/skyfall/firehose/account_message.rb
128
123
  - lib/skyfall/firehose/commit_message.rb
@@ -146,13 +141,13 @@ files:
146
141
  - lib/skyfall/stream.rb
147
142
  - lib/skyfall/version.rb
148
143
  - sig/skyfall.rbs
149
- homepage: https://github.com/mackuba/skyfall
144
+ homepage: https://ruby.sdk.blue
150
145
  licenses:
151
146
  - Zlib
152
147
  metadata:
153
- bug_tracker_uri: https://github.com/mackuba/skyfall/issues
154
- changelog_uri: https://github.com/mackuba/skyfall/blob/master/CHANGELOG.md
155
- source_code_uri: https://github.com/mackuba/skyfall
148
+ bug_tracker_uri: https://tangled.org/mackuba.eu/skyfall/issues
149
+ changelog_uri: https://tangled.org/mackuba.eu/skyfall/blob/master/CHANGELOG.md
150
+ source_code_uri: https://tangled.org/mackuba.eu/skyfall
156
151
  rdoc_options: []
157
152
  require_paths:
158
153
  - lib
@@ -167,7 +162,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
162
  - !ruby/object:Gem::Version
168
163
  version: '0'
169
164
  requirements: []
170
- rubygems_version: 3.6.2
165
+ rubygems_version: 4.0.3
171
166
  specification_version: 4
172
- summary: A Ruby gem for streaming data from the Bluesky/AtProto firehose
167
+ summary: A Ruby gem for streaming data from the Bluesky/ATProto firehose
173
168
  test_files: []
@@ -1,84 +0,0 @@
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::Firehose.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
@@ -1,54 +0,0 @@
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.). This example uses a Jetstream connection.
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
- # tell Jetstream to send us only post records
21
- sky = Skyfall::Jetstream.new('jetstream2.us-east.bsky.network', { wanted_collections: [:bsky_post] })
22
-
23
- sky.on_message do |msg|
24
- # we're only interested in repo commit messages
25
- next if msg.type != :commit
26
-
27
- msg.operations.each do |op|
28
- # ignore any operations other than "create post"
29
- next unless op.action == :create && op.type == :bsky_post
30
-
31
- text = op.raw_record['text'].to_s.downcase
32
-
33
- if terms.any? { |x| text.include?(x) }
34
- owner_handle = get_user_handle(op.repo)
35
- puts "\n#{msg.time.getlocal} @#{owner_handle}: #{op.raw_record['text']}"
36
- end
37
- end
38
- end
39
-
40
- def get_user_handle(did)
41
- url = "https://plc.directory/#{did}"
42
- json = JSON.parse(URI.open(url).read)
43
- json['alsoKnownAs'][0].gsub('at://', '')
44
- end
45
-
46
- sky.on_connect { puts "Connected" }
47
- sky.on_disconnect { puts "Disconnected" }
48
- sky.on_reconnect { puts "Reconnecting..." }
49
- sky.on_error { |e| puts "ERROR: #{e}" }
50
-
51
- # close the connection cleanly on Ctrl+C
52
- trap("SIGINT") { sky.disconnect }
53
-
54
- sky.connect
@@ -1,34 +0,0 @@
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::Firehose.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
@@ -1,262 +0,0 @@
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::Firehose.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
@@ -1,48 +0,0 @@
1
- require 'websocket/driver'
2
- require_relative 'firehose'
3
-
4
- module WebSocket
5
- class Driver
6
- class Hybi
7
- def emit_message
8
- message = @extensions.process_incoming_message(@message)
9
- @message = nil
10
-
11
- payload = message.data
12
-
13
- case message.opcode
14
- when OPCODES[:text] then
15
- payload = Driver.encode(payload, Encoding::UTF_8)
16
- payload = nil unless payload.valid_encoding?
17
- # when OPCODES[:binary]
18
- # payload = payload.bytes.to_a
19
- end
20
-
21
- if payload
22
- emit(:message, MessageEvent.new(payload))
23
- else
24
- fail(:encoding_error, 'Could not decode a text frame as UTF-8')
25
- end
26
- rescue ::WebSocket::Extensions::ExtensionError => error
27
- fail(:extension_error, error.message)
28
- end
29
- end
30
- end
31
- end
32
-
33
- module Skyfall
34
- class Firehose
35
- def handle_message(msg)
36
- data = msg.data #.pack('C*')
37
- @handlers[:raw_message]&.call(data)
38
-
39
- if @handlers[:message]
40
- atp_message = Message.new(data)
41
- @cursor = atp_message.seq
42
- @handlers[:message].call(atp_message)
43
- else
44
- @cursor = nil
45
- end
46
- end
47
- end
48
- end