ratproto 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7897b08049db9ed366fe7bace08dc7fba37089accf140df8a266071c1d30c676
4
- data.tar.gz: e8a7d6779e21941e3fb02a6c54683a22bb3d8bd65be4bef10d64507c86f193fe
3
+ metadata.gz: dd51fed594f086af2d560047baf82a7adfe1f29483421d8dd1c4e0ffef8e00f4
4
+ data.tar.gz: b91f3eaac638d75e04d3030fb9cf59f70e6d8589f223fe0c1a846837ffa1336e
5
5
  SHA512:
6
- metadata.gz: 847b25a95b46ad7b07898790205e5e87761746b96c9488554548f27b74052f9086374e896a12ba8562d55f3560a0139d91ebf67eac495161bec27590ac5e4b67
7
- data.tar.gz: cdb37c2c7d782977c75207ad17cfed669e183beb73b6fb71f8fe9cc135516879aac8966814afd559236be1d1ad58907e822b296069c9a1f059fd5916cc3d17a3
6
+ metadata.gz: 5cb31e38a0d44e863da64ccbd826258d48c8ae6451e63279e577213fe482ed4122c084a338264c8dcb118369a3fd8e571678a02bf070537368b89b5d4011b91e
7
+ data.tar.gz: 85cd4e59791b9f09a987dbe1ac2e8ebfe2d4d991b7033d851d7d26e8b56f21b19816fb6fb763c9272491378d98c2e3fe7e4acdb0a7925e17a15ac31eba8efa59
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [0.3.0] - 2026-07-30
2
+
3
+ * `stream`:
4
+ - added option `--print-cursor` (`-s`)
5
+ - print account status change messages
6
+ - added option `--[no-]account` (`-a`) to enable/disable account messages
7
+ - print the timestamp of the last message on stop
8
+ - allow passing handles with `@` to `-d` option
9
+ - status messages are printed to stderr
10
+ - suggest that `-j` might be missing on connection error
11
+ * added `stream-labels` command which works similarly to `stream`, but streams labels from a labeller
12
+ - pass labeller service as a hostname or a DID/handle of an account
13
+ - filter labels by type (value) or by pattern in the labelled URI
14
+
15
+ ## [0.2.0] - 2026-07-28
16
+
17
+ - converted the codebase from a single script in `exe/rat` to per-command classes using Clamp framework
18
+
1
19
  ## [0.1.2] - 2026-05-24
2
20
 
3
21
  - `resolve`: added `-d` and `-p` options to print only the DID or the PDS endpoint respectively
data/README.md CHANGED
@@ -14,7 +14,7 @@ It builds on top of the existing ATProto Ruby gems:
14
14
 
15
15
  ## Installation
16
16
 
17
- To run this tool, you need some reasonably recent version of Ruby installed – it should run on Ruby 2.6 and above, although it's recommended to use a version that still gets maintainance updates, i.e. currently 3.2+.
17
+ To run this tool, you need some reasonably recent version of Ruby installed – it should run on Ruby 2.6 and above, although it's recommended to use a version that still gets maintainance updates, i.e. currently 3.3+.
18
18
 
19
19
  An older version of Ruby (2.6.x) that should work is shipped with macOS versions 11.0 or later, a recent version of Ruby is also likely to be already installed on most Linux systems, or at least available through the OS's package manager. More installation options can be found on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/).
20
20
 
data/exe/rat CHANGED
@@ -2,319 +2,6 @@
2
2
 
3
3
  $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
4
4
 
5
- require 'didkit'
6
- require 'json'
7
- require 'minisky'
8
- require 'optparse'
9
- require 'time'
10
- require 'uri'
5
+ require 'ratproto/cli'
11
6
 
12
- require 'ratproto/version'
13
-
14
- DID_REGEXP = /\Adid:[a-z]+:[a-zA-Z0-9.\-_]+\z/
15
- NSID_REGEXP = /\A[a-z0-9]+(\.[a-z0-9]+)+\z/
16
- HANDLE_REGEXP = /\A[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)+\z/
17
-
18
- def print_help
19
- puts <<~HELP
20
- rat #{RatProto::VERSION} 🐀
21
-
22
- Usage:
23
- rat fetch at://uri
24
- rat stream <firehose-host> [options]
25
- rat resolve <did>|<handle>
26
-
27
- Commands:
28
- fetch Fetch a single record given its at:// URI
29
- stream Stream events from a relay / PDS firehose
30
- resolve Resolve a DID or @handle
31
- help Show this help
32
- version Show version
33
-
34
- Stream options:
35
- -j, --jetstream Use a Jetstream source instead of a CBOR firehose
36
- -r, --cursor CURSOR Start from a given cursor
37
- -d, --did DID[,DID...] Filter only events from given DID(s)
38
- (can be passed multiple times)
39
- -c, --collection NSID[,NSID...] Filter only events of given collection(s)
40
- (can be passed multiple times)
41
- HELP
42
- end
43
-
44
- def abort_with_error(message)
45
- puts message
46
- exit 1
47
- end
48
-
49
- def check_stream_did(did)
50
- if did =~ DID_REGEXP
51
- did
52
- elsif did =~ HANDLE_REGEXP
53
- if resolved_did = DID.resolve_handle(did)
54
- resolved_did.to_s
55
- else
56
- abort_with_error "Error: couldn't resolve handle @#{did}"
57
- end
58
- else
59
- abort_with_error "Error: #{did.inspect} is not a valid DID"
60
- end
61
- end
62
-
63
- def validate_nsid(collection)
64
- unless collection =~ NSID_REGEXP
65
- abort_with_error "Error: #{collection.inspect} is not a valid collection NSID"
66
- end
67
- end
68
-
69
- def parse_at_uri(uri)
70
- unless uri.start_with?('at://')
71
- abort_with_error "Error: not an at:// URI: #{uri.inspect}"
72
- end
73
-
74
- unless uri =~ /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/
75
- abort_with_error "Error: invalid at:// URI: #{uri.inspect}"
76
- end
77
-
78
- [$1, $2, $3]
79
- end
80
-
81
- def run_fetch(args)
82
- uri = args.shift
83
-
84
- if uri.nil?
85
- abort_with_error "Usage: #{$PROGRAM_NAME} fetch <at://uri>"
86
- end
87
-
88
- if !args.empty?
89
- abort_with_error "Error: Unexpected arguments for fetch: #{args.join(' ')}"
90
- end
91
-
92
- repo, collection, rkey = parse_at_uri(uri)
93
-
94
- begin
95
- pds = DID.new(repo).document.pds_host
96
- sky = Minisky.new(pds, nil)
97
-
98
- response = sky.get_request('com.atproto.repo.getRecord', {
99
- repo: repo, collection: collection, rkey: rkey
100
- })
101
- rescue StandardError => e
102
- abort_with_error "Error loading record: #{e.class}: #{e.message}"
103
- end
104
-
105
- puts JSON.pretty_generate(response['value'])
106
- end
107
-
108
- def parse_resolve_options(args)
109
- options = {}
110
-
111
- parser = OptionParser.new do |opts|
112
- opts.banner = "Usage: #{$PROGRAM_NAME} resolve <did>|<handle> [options]"
113
-
114
- opts.on('-d', '--did', 'Print only the DID resolved from the handle') do
115
- options[:only_did] = true
116
- end
117
-
118
- opts.on('-p', '--pds', 'Print only the PDS endpoint') do
119
- options[:only_pds] = true
120
- end
121
-
122
- opts.on('-h', '--help', 'Show resolve-specific help') do
123
- puts opts
124
- exit
125
- end
126
- end
127
-
128
- remaining = []
129
-
130
- begin
131
- parser.order!(args) { |other| remaining << other }
132
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
133
- puts "Error: #{e.message}"
134
- puts parser
135
- exit 1
136
- end
137
-
138
- if options[:only_did] && options[:only_pds]
139
- abort_with_error "--pds and --did options cannot be used together"
140
- end
141
-
142
- [options, remaining]
143
- end
144
-
145
- def run_resolve(args)
146
- options, arguments = parse_resolve_options(args)
147
- target = arguments.shift
148
-
149
- if target.nil?
150
- abort_with_error "Usage: #{$PROGRAM_NAME} resolve <did>|<handle> [options]"
151
- end
152
-
153
- if !args.empty?
154
- abort_with_error "Error: Unexpected arguments for resolve: #{args.join(' ')}"
155
- end
156
-
157
- did = DID.resolve_handle(target)
158
-
159
- if did.nil?
160
- abort_with_error "Couldn't resolve #{target}"
161
- end
162
-
163
- if options[:only_did]
164
- puts did
165
- elsif options[:only_pds]
166
- puts did.document.pds_endpoint
167
- else
168
- puts JSON.pretty_generate(did.document.json)
169
- end
170
- rescue StandardError => e
171
- abort_with_error "Error resolving #{target.inspect}: #{e.class}: #{e.message}"
172
- end
173
-
174
- def parse_stream_options(args)
175
- options = {}
176
-
177
- parser = OptionParser.new do |opts|
178
- opts.banner = "Usage: #{$PROGRAM_NAME} stream <relay.host> [options]"
179
-
180
- opts.on('-j', '--jetstream', 'Use a Jetstream source') do
181
- options[:jetstream] = true
182
- end
183
-
184
- opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from a given cursor') do |cursor|
185
- options[:cursor] = cursor
186
- end
187
-
188
- opts.on('-dLIST', '--did=LIST', 'Filter only events from given DID(s) (comma-separated or repeated)') do |list|
189
- items = list.split(',').map(&:strip).reject(&:empty?)
190
-
191
- if items.empty?
192
- abort_with_error "Error: empty argument to -d/--did"
193
- end
194
-
195
- options[:dids] ||= []
196
- options[:dids] += items
197
- end
198
-
199
- opts.on('-cLIST', '--collection=LIST', 'Filter only events of given collections') do |list|
200
- items = list.split(',').map(&:strip).reject(&:empty?)
201
-
202
- if items.empty?
203
- abort_with_error "Error: empty argument to -c/--collection"
204
- end
205
-
206
- options[:collections] ||= []
207
- options[:collections] += items
208
- end
209
-
210
- opts.on('-h', '--help', 'Show stream-specific help') do
211
- puts opts
212
- exit
213
- end
214
- end
215
-
216
- remaining = []
217
-
218
- begin
219
- parser.order!(args) { |other| remaining << other }
220
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
221
- puts "Error: #{e.message}"
222
- puts parser
223
- exit 1
224
- end
225
-
226
- [options, remaining]
227
- end
228
-
229
- def run_stream(args)
230
- options, arguments = parse_stream_options(args)
231
-
232
- service = arguments.shift
233
-
234
- if service.nil?
235
- abort_with_error "Usage: #{$PROGRAM_NAME} stream <firehose-host> [options]"
236
- end
237
-
238
- if !arguments.empty?
239
- abort_with_error "Error: Unexpected arguments for stream: #{arguments.join(' ')}"
240
- end
241
-
242
- if options[:cursor] && options[:cursor] !~ /\A\d+\z/
243
- abort_with_error "Error: cursor must be a decimal integer, got: #{options[:cursor].inspect}"
244
- end
245
-
246
- if options[:dids]
247
- options[:dids] = options[:dids].map { |did| check_stream_did(did) }
248
- end
249
-
250
- if options[:collections]
251
- options[:collections].each { |collection| validate_nsid(collection) }
252
- end
253
-
254
- if options[:jetstream]
255
- jet_opts = {}
256
- jet_opts[:cursor] = options[:cursor].to_i if options[:cursor]
257
-
258
- # pass DID/collection filters to Jetstream to filter events server-side
259
- jet_opts[:wanted_dids] = options[:dids] if options[:dids]
260
- jet_opts[:wanted_collections] = options[:collections] if options[:collections]
261
-
262
- sky = Skyfall::Jetstream.new(service, jet_opts)
263
- else
264
- cursor = options[:cursor]&.to_i
265
- sky = Skyfall::Firehose.new(service, :subscribe_repos, cursor)
266
- end
267
-
268
- sky.on_connecting { |url| puts "Connecting to #{url}..." }
269
- sky.on_connect { puts "Connected" }
270
- sky.on_disconnect { puts "Disconnected" }
271
- sky.on_reconnect { puts "Connection lost, trying to reconnect..." }
272
- sky.on_timeout { puts "Connection stalled, triggering a reconnect..." }
273
- sky.on_error { |e| puts "ERROR: #{e}" }
274
-
275
- sky.on_message do |msg|
276
- next unless msg.type == :commit
277
- next if options[:dids] && !options[:dids].include?(msg.repo)
278
-
279
- time = msg.time.getlocal.iso8601
280
-
281
- msg.operations.each do |op|
282
- next if options[:collections] && !options[:collections].include?(op.collection)
283
-
284
- json = op.raw_record && JSON.generate(op.raw_record)
285
- puts "[#{time}] #{msg.repo} #{op.action.inspect} #{op.collection} #{op.rkey} #{json}"
286
- end
287
- end
288
-
289
- trap('SIGINT') do
290
- puts 'Disconnecting...'
291
- sky.disconnect
292
- end
293
-
294
- sky.connect
295
-
296
- puts "Stopped stream at cursor #{sky.cursor}"
297
- end
298
-
299
- if ARGV.empty?
300
- print_help
301
- exit
302
- end
303
-
304
- cmd = ARGV.shift
305
-
306
- case cmd
307
- when 'help', '--help', '-h'
308
- print_help
309
- when 'version', '--version'
310
- puts "RatProto #{RatProto::VERSION} 🐀"
311
- when 'fetch'
312
- run_fetch(ARGV)
313
- when 'resolve'
314
- run_resolve(ARGV)
315
- when 'stream'
316
- require 'skyfall'
317
- run_stream(ARGV)
318
- else
319
- abort_with_error "Error: unknown command: #{cmd}"
320
- end
7
+ RatProto::CLI.run
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'clamp'
4
+
5
+ require_relative 'version'
6
+ require_relative 'commands/base'
7
+ require_relative 'commands/fetch'
8
+ require_relative 'commands/resolve'
9
+ require_relative 'commands/stream'
10
+ require_relative 'commands/stream_labels'
11
+
12
+ Clamp.allow_options_after_parameters = true
13
+
14
+ module RatProto
15
+ module Commands
16
+ class Version < Base
17
+ banner 'Show version'
18
+
19
+ def execute
20
+ puts "RatProto #{RatProto::VERSION} 🐀"
21
+ end
22
+ end
23
+
24
+ class Help < Base
25
+ banner 'Show help for rat or one of its commands'
26
+
27
+ parameter '[COMMAND]', 'command to describe'
28
+
29
+ def execute
30
+ program_name = File.basename($PROGRAM_NAME)
31
+
32
+ if command
33
+ command_class = RatProto::CLI.find_subcommand_class(command)
34
+ signal_usage_error "unknown command: #{command}" unless command_class
35
+
36
+ puts command_class.help("#{program_name} #{command}")
37
+ else
38
+ puts RatProto::CLI.help(program_name)
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ class CLI < Commands::Base
45
+ banner "RatProto #{RatProto::VERSION} 🐀 — Ruby ATProto CLI"
46
+
47
+ option '--version', :flag, 'show version' do
48
+ Commands::Version.run("", [])
49
+ exit
50
+ end
51
+
52
+ subcommand 'fetch', 'fetch a single record given its at:// URI', Commands::Fetch
53
+ subcommand 'stream', 'stream events from a relay or PDS firehose', Commands::Stream
54
+ subcommand 'stream-labels', 'stream labels a labeller firehose', Commands::StreamLabels
55
+ subcommand 'resolve', 'resolve a DID or @handle', Commands::Resolve
56
+ subcommand 'help', 'show help', Commands::Help
57
+ subcommand 'version', 'show version', Commands::Version
58
+ end
59
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'clamp'
4
+
5
+ module RatProto
6
+ module Commands
7
+ class Base < Clamp::Command
8
+ DID_REGEXP = /\Adid:[a-z]+:[a-zA-Z0-9.\-_]+\z/
9
+ NSID_REGEXP = /\A[a-z0-9]+(\.[a-z0-9]+)+\z/
10
+ HANDLE_REGEXP = /\A[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)+\z/
11
+
12
+ private
13
+
14
+ def log(text = nil)
15
+ $stderr.puts text
16
+ end
17
+
18
+ def parse_at_uri(value)
19
+ signal_usage_error "not an at:// URI: #{value.inspect}" unless value.start_with?('at://')
20
+
21
+ match = value.match(/\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/)
22
+ signal_usage_error "invalid at:// URI: #{value.inspect}" unless match
23
+
24
+ match.captures
25
+ end
26
+
27
+ def comma_separated(values, option_name)
28
+ values = (values || []).map { |value|
29
+ args = value.split(',').map(&:strip).reject(&:empty?)
30
+ signal_usage_error "empty argument to #{option_name}" if args.empty?
31
+ args
32
+ }
33
+
34
+ values.flatten
35
+ end
36
+
37
+ def map_argument_to_did(did)
38
+ if did =~ DID_REGEXP
39
+ did
40
+ elsif did.gsub(/\A@/, '') =~ HANDLE_REGEXP
41
+ if resolved_did = DID.resolve_handle(did)
42
+ resolved_did.to_s
43
+ else
44
+ signal_error "couldn't resolve handle @#{did}"
45
+ end
46
+ else
47
+ signal_usage_error "#{did.inspect} is not a valid DID"
48
+ end
49
+ end
50
+
51
+ def validate_nsid(collection)
52
+ return collection if collection =~ NSID_REGEXP
53
+
54
+ signal_usage_error "#{collection.inspect} is not a valid collection NSID"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'didkit'
4
+ require 'json'
5
+
6
+ require_relative 'base'
7
+
8
+ module RatProto
9
+ module Commands
10
+ class Fetch < Base
11
+ banner 'Fetch a single record given its at:// URI'
12
+
13
+ parameter 'URI', 'ATProto record URI'
14
+
15
+ def execute
16
+ require 'minisky'
17
+
18
+ repo, collection, rkey = parse_at_uri(uri)
19
+ response = load_record(repo, collection, rkey)
20
+ puts JSON.pretty_generate(response['value'])
21
+ end
22
+
23
+
24
+ private
25
+
26
+ def load_record(repo, collection, rkey)
27
+ pds = DID.new(repo).document.pds_host
28
+ sky = Minisky.new(pds, nil)
29
+
30
+ sky.get_request('com.atproto.repo.getRecord', {
31
+ repo: repo, collection: collection, rkey: rkey
32
+ })
33
+ rescue StandardError => e
34
+ signal_error "loading record: #{e.class}: #{e.message}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'didkit'
4
+ require 'json'
5
+
6
+ require_relative 'base'
7
+
8
+ module RatProto
9
+ module Commands
10
+ class Resolve < Base
11
+ banner 'Resolve a DID or @handle'
12
+
13
+ parameter 'TARGET', 'DID or handle'
14
+
15
+ option ['-d', '--did'], :flag, 'print only the DID resolved from the handle'
16
+ option ['-p', '--pds'], :flag, 'print only the PDS endpoint'
17
+
18
+ def execute
19
+ signal_usage_error '--pds and --did options cannot be used together' if did? && pds?
20
+
21
+ begin
22
+ resolved_did = DID.resolve_handle(target)
23
+ rescue StandardError => e
24
+ signal_error "resolving #{target.inspect}: #{e.class}: #{e.message}"
25
+ end
26
+
27
+ if resolved_did.nil?
28
+ signal_error "couldn't resolve #{target}"
29
+ end
30
+
31
+ if did?
32
+ puts resolved_did
33
+ elsif pds?
34
+ puts resolved_did.document.pds_endpoint
35
+ else
36
+ puts JSON.pretty_generate(resolved_did.document.json)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'didkit'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ require_relative 'base'
8
+
9
+ module RatProto
10
+ module Commands
11
+ class Stream < Base
12
+ banner 'Stream events from a relay or PDS firehose'
13
+
14
+ parameter 'SERVICE', 'relay, PDS or Jetstream hostname'
15
+
16
+ option ['-j', '--jetstream'], :flag, 'use a Jetstream source'
17
+
18
+ option(
19
+ ['-r', '--cursor'], 'CURSOR',
20
+ 'start from a given cursor (pass 0 to read from the beginning of the playback window)'
21
+ ) do |value|
22
+ raise ArgumentError, 'must be a decimal integer' unless value =~ /\A\d+\z/
23
+ value.to_i
24
+ end
25
+
26
+ option ['-d', '--did'], 'DID[,DID...]', 'filter events by DID (comma-separated or repeated)',
27
+ multivalued: true, attribute_name: :did_values
28
+
29
+ option ['-c', '--collection'], 'NSID[,NSID...]', 'filter events by collection (comma-separated or repeated)',
30
+ multivalued: true, attribute_name: :collection_values
31
+
32
+ option ['-a', '--[no-]account'], :flag, 'print account status events (default: enabled unless filtering by collection)'
33
+ option ['-s', '--print-cursor'], :flag, 'only print current cursor (seq)'
34
+
35
+
36
+ def execute
37
+ require 'skyfall'
38
+
39
+ @dids = comma_separated(did_values, '-d/--did')
40
+ @collections = comma_separated(collection_values, '-c/--collection')
41
+
42
+ @dids = (@dids.length > 0) ? @dids.map { |did| map_argument_to_did(did) } : nil
43
+ @collections = (@collections.length > 0) ? @collections.map { |collection| validate_nsid(collection) } : nil
44
+
45
+ @sky = build_stream
46
+ configure_interrupt_handler
47
+
48
+ if print_cursor?
49
+ @sky.on_message do |msg|
50
+ print_cursor_and_stop(msg)
51
+ next
52
+ end
53
+
54
+ @sky.connect
55
+ else
56
+ configure_callbacks
57
+ @sky.connect
58
+
59
+ log "Stopped stream at cursor #{@sky.cursor}" + (@last_timestamp ? " (#{@last_timestamp})" : "")
60
+ end
61
+ end
62
+
63
+
64
+ private
65
+
66
+ def build_stream
67
+ if jetstream?
68
+ options = {}
69
+ options[:cursor] = cursor if cursor
70
+
71
+ # pass DID/collection filters to Jetstream to filter events server-side
72
+ options[:wanted_dids] = @dids if @dids
73
+ options[:wanted_collections] = @collections if @collections
74
+
75
+ Skyfall::Jetstream.new(service, options)
76
+ else
77
+ Skyfall::Firehose.new(service, :subscribe_repos, cursor)
78
+ end
79
+ end
80
+
81
+ def configure_callbacks
82
+ @sky.on_connecting { |url| log "Connecting to #{url}..." }
83
+ @sky.on_connect { log "Connected" }
84
+ @sky.on_disconnect { log "Disconnected" }
85
+ @sky.on_reconnect { log "Connection lost, trying to reconnect..." }
86
+ @sky.on_timeout { log "Connection stalled, triggering a reconnect..." }
87
+ @sky.on_error { |e| report_error(e) }
88
+
89
+ @sky.on_message { |msg| process_message(msg) }
90
+ end
91
+
92
+ def report_error(error)
93
+ log "ERROR: #{error.message}"
94
+
95
+ if !jetstream? && error.message.include?('Unexpected response code')
96
+ log "If this is a Jetstream server, retry with -j / --jetstream."
97
+ end
98
+ end
99
+
100
+ def process_message(msg)
101
+ case msg.type
102
+ when :commit
103
+ print_commit_message(msg)
104
+ when :account
105
+ print_account_message(msg) if show_account_messages?
106
+ end
107
+ end
108
+
109
+ def print_commit_message(msg)
110
+ return if @dids && !@dids.include?(msg.repo)
111
+
112
+ time = msg.time.getlocal.iso8601
113
+ @last_timestamp = time
114
+
115
+ msg.operations.each do |op|
116
+ next if @collections && !@collections.include?(op.collection)
117
+
118
+ json = op.raw_record && JSON.generate(op.raw_record.except('$type'))
119
+ puts "[#{time}] (#{msg.seq}) #{msg.repo} #{op.action.inspect} #{op.collection} #{op.rkey} #{json}"
120
+ end
121
+ end
122
+
123
+ def print_account_message(msg)
124
+ return if @dids && !@dids.include?(msg.repo)
125
+
126
+ time = msg.time.getlocal.iso8601
127
+ @last_timestamp = time
128
+
129
+ status = (msg.active?) ? :active : msg.status
130
+
131
+ puts "[#{time}] (#{msg.seq}) #{msg.repo} #account (status = #{status.inspect})"
132
+ end
133
+
134
+ def show_account_messages?
135
+ if account? == nil
136
+ # enabled by default if -c is not used
137
+ @collections.nil?
138
+ else
139
+ # if enabled/disabled explicitly, follow user's choice
140
+ account?
141
+ end
142
+ end
143
+
144
+ def configure_interrupt_handler
145
+ trap('SIGINT') do
146
+ log
147
+ log 'Disconnecting...'
148
+ @sky.disconnect
149
+ end
150
+ end
151
+
152
+ def print_cursor_and_stop(msg)
153
+ return if @disconnecting
154
+
155
+ puts msg.seq
156
+ @sky.disconnect
157
+ @disconnecting = true
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'didkit'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ require_relative 'base'
8
+
9
+ module RatProto
10
+ module Commands
11
+ class StreamLabels < Base
12
+ banner 'Stream labels from a labeller firehose'
13
+
14
+ parameter 'SERVICE', 'labeller hostname, or a @handle/DID of the labeller account (handle must start with @)'
15
+
16
+ option ['-r', '--cursor'], 'CURSOR', 'start from a given cursor' do |value|
17
+ raise ArgumentError, 'must be a decimal integer' unless value =~ /\A\d+\z/
18
+ value.to_i
19
+ end
20
+
21
+ option ['-l', '--labels'], 'LABEL[,LABEL...]', 'filter events by label type (comma-separated or repeated)',
22
+ multivalued: true, attribute_name: :label_types
23
+
24
+ option ['-t', '--target'], 'PATTERN', "filter by labelled DID/URI (can be a pattern with '*' as a wildcard; repeatable)",
25
+ multivalued: true, attribute_name: :target_patterns
26
+
27
+ option ['-s', '--print-cursor'], :flag, 'only print current cursor (seq)'
28
+
29
+
30
+ def execute
31
+ require 'skyfall'
32
+
33
+ @labels = (label_types.length > 0) ? comma_separated(label_types, '-l/--labels') : nil
34
+ @targets = (target_patterns.length > 0) ? target_patterns.map { |pattern| target_regexp(pattern) } : nil
35
+ @service = determine_hostname(service)
36
+
37
+ @sky = Skyfall::Firehose.new(@service, :subscribe_labels, cursor)
38
+ configure_interrupt_handler
39
+
40
+ if print_cursor?
41
+ @sky.on_message do |msg|
42
+ print_cursor_and_stop(msg)
43
+ next
44
+ end
45
+
46
+ @sky.connect
47
+ else
48
+ configure_callbacks
49
+ @sky.connect
50
+
51
+ log "Stopped stream at cursor #{@sky.cursor}" + (@last_timestamp ? " (#{@last_timestamp})" : "")
52
+ end
53
+ end
54
+
55
+
56
+ private
57
+
58
+ def target_regexp(pattern)
59
+ source = Regexp.escape(pattern).gsub('\*', '.*')
60
+ Regexp.new("\\A#{source}\\z")
61
+ end
62
+
63
+ def determine_hostname(service)
64
+ if service =~ /\A(https?:\/\/)?[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)+(:[0-9]+)?\z/
65
+ return service
66
+ end
67
+
68
+ if service =~ DID_REGEXP
69
+ did = DID.new(service)
70
+ else
71
+ handle = service.gsub(/\A@/, '')
72
+
73
+ if handle =~ HANDLE_REGEXP
74
+ if resolved_did = DID.resolve_handle(handle)
75
+ did = resolved_did
76
+ else
77
+ signal_error "couldn't resolve handle @#{handle}"
78
+ end
79
+ else
80
+ signal_usage_error "#{service.inspect} is not a valid hostname, DID or handle"
81
+ end
82
+ end
83
+
84
+ did.document.labeller_host
85
+ end
86
+
87
+ def configure_callbacks
88
+ @sky.on_connecting { |url| log "Connecting to #{url}..." }
89
+ @sky.on_connect { log "Connected" }
90
+ @sky.on_disconnect { log "Disconnected" }
91
+ @sky.on_reconnect { log "Connection lost, trying to reconnect..." }
92
+ @sky.on_timeout { log "Connection stalled, triggering a reconnect..." }
93
+ @sky.on_error { |e| log "ERROR: #{e}" }
94
+
95
+ @sky.on_message do |msg|
96
+ next unless msg.type == :labels
97
+
98
+ msg.labels.each do |label|
99
+ next if @labels && !@labels.include?(label.value)
100
+ next if @targets && @targets.none? { |pattern| pattern.match?(label.subject) }
101
+
102
+ @last_timestamp = label.created_at
103
+
104
+ json = JSON.generate(label.data.except('cid', 'cts', 'sig', 'src', 'ver'))
105
+ puts "[#{label.created_at}] (#{msg.seq}) #{json}"
106
+ end
107
+ end
108
+ end
109
+
110
+ def configure_interrupt_handler
111
+ trap('SIGINT') do
112
+ log
113
+ log 'Disconnecting...'
114
+ @sky.disconnect
115
+ end
116
+ end
117
+
118
+ def print_cursor_and_stop(msg)
119
+ return if @disconnecting
120
+
121
+ puts msg.seq
122
+ @sky.disconnect
123
+ @disconnecting = true
124
+ end
125
+ end
126
+ end
127
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RatProto
4
- VERSION = "0.1.2"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ratproto
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
@@ -69,6 +69,26 @@ dependencies:
69
69
  - - "<"
70
70
  - !ruby/object:Gem::Version
71
71
  version: '2.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: clamp
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '1.5'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.5'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '2.0'
72
92
  email:
73
93
  - jakub.suder@gmail.com
74
94
  executables:
@@ -81,6 +101,12 @@ files:
81
101
  - README.md
82
102
  - exe/rat
83
103
  - lib/ratproto.rb
104
+ - lib/ratproto/cli.rb
105
+ - lib/ratproto/commands/base.rb
106
+ - lib/ratproto/commands/fetch.rb
107
+ - lib/ratproto/commands/resolve.rb
108
+ - lib/ratproto/commands/stream.rb
109
+ - lib/ratproto/commands/stream_labels.rb
84
110
  - lib/ratproto/version.rb
85
111
  - sig/ratproto.rbs
86
112
  homepage: https://ruby.sdk.blue
@@ -104,7 +130,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
130
  - !ruby/object:Gem::Version
105
131
  version: '0'
106
132
  requirements: []
107
- rubygems_version: 4.0.10
133
+ rubygems_version: 3.6.9
108
134
  specification_version: 4
109
135
  summary: Ruby CLI tool for accessing Bluesky API / ATProto
110
136
  test_files: []